- Interactive request generator with 20 state support - Quick state law lookup tool - Security-hardened input validation - Copy-paste templates for non-technical users - MIT licensed civic tech project
512 lines
20 KiB
TypeScript
Executable file
512 lines
20 KiB
TypeScript
Executable file
#!/usr/bin/env bun
|
||
|
||
/**
|
||
* Flock Safety Public Records Request Generator (Security Hardened)
|
||
*
|
||
* Interactive script to generate properly formatted public records requests
|
||
* for Flock Safety camera systems with correct state-specific legal citations.
|
||
*
|
||
* Security Features:
|
||
* - Input validation and sanitization
|
||
* - Path traversal prevention
|
||
* - Length limits on all inputs
|
||
* - Email format validation
|
||
* - Safe filename generation
|
||
*/
|
||
|
||
import { writeFileSync } from 'fs';
|
||
import { join } from 'path';
|
||
import * as readline from 'readline';
|
||
|
||
// State-specific public records law information
|
||
const STATE_LAWS = {
|
||
AZ: {
|
||
name: "Arizona",
|
||
lawName: "Arizona Public Records Law",
|
||
statute: "A.R.S. §39-121 et seq.",
|
||
responseTime: "promptly as required by A.R.S. §39-121",
|
||
specificTimeframe: null,
|
||
notes: "Arizona does not have a specific mandated response time in days"
|
||
},
|
||
CO: {
|
||
name: "Colorado",
|
||
lawName: "Colorado Open Records Act (CORA)",
|
||
statute: "C.R.S. §24-72-201 et seq.",
|
||
responseTime: "within 10 business days as required by C.R.S. §24-72-203(3)(b)",
|
||
specificTimeframe: 10,
|
||
notes: "Colorado allows 10 business days for response"
|
||
},
|
||
CA: {
|
||
name: "California",
|
||
lawName: "California Public Records Act",
|
||
statute: "Cal. Gov. Code §7920.000 et seq.",
|
||
responseTime: "within 10 days as required by Cal. Gov. Code §7922.535",
|
||
specificTimeframe: 10,
|
||
notes: "California requires response within 10 days"
|
||
},
|
||
TX: {
|
||
name: "Texas",
|
||
lawName: "Texas Public Information Act",
|
||
statute: "Tex. Gov't Code §552.001 et seq.",
|
||
responseTime: "promptly as required by Tex. Gov't Code §552.221",
|
||
specificTimeframe: 10,
|
||
notes: "Texas requires response within 10 business days"
|
||
},
|
||
IL: {
|
||
name: "Illinois",
|
||
lawName: "Illinois Freedom of Information Act (FOIA)",
|
||
statute: "5 ILCS 140/1 et seq.",
|
||
responseTime: "within 5 business days as required by 5 ILCS 140/3(d)",
|
||
specificTimeframe: 5,
|
||
notes: "Illinois requires response within 5 business days"
|
||
},
|
||
VA: {
|
||
name: "Virginia",
|
||
lawName: "Virginia Freedom of Information Act (FOIA)",
|
||
statute: "Va. Code §2.2-3700 et seq.",
|
||
responseTime: "within 5 working days as required by Va. Code §2.2-3704(B)",
|
||
specificTimeframe: 5,
|
||
notes: "Virginia requires response within 5 working days"
|
||
},
|
||
NY: {
|
||
name: "New York",
|
||
lawName: "New York Freedom of Information Law (FOIL)",
|
||
statute: "N.Y. Pub. Off. Law §87 et seq.",
|
||
responseTime: "within 5 business days as required by N.Y. Pub. Off. Law §89(3)",
|
||
specificTimeframe: 5,
|
||
notes: "New York requires acknowledgment within 5 business days"
|
||
},
|
||
FL: {
|
||
name: "Florida",
|
||
lawName: "Florida Public Records Law",
|
||
statute: "Fla. Stat. §119.01 et seq.",
|
||
responseTime: "within a reasonable time as required by Fla. Stat. §119.07(1)(a)",
|
||
specificTimeframe: null,
|
||
notes: "Florida requires 'reasonable time' - no specific deadline"
|
||
},
|
||
WA: {
|
||
name: "Washington",
|
||
lawName: "Washington Public Records Act",
|
||
statute: "RCW 42.56 et seq.",
|
||
responseTime: "within 5 business days as required by RCW 42.56.520",
|
||
specificTimeframe: 5,
|
||
notes: "Washington requires response within 5 business days"
|
||
},
|
||
OR: {
|
||
name: "Oregon",
|
||
lawName: "Oregon Public Records Law",
|
||
statute: "ORS 192.311 et seq.",
|
||
responseTime: "within a reasonable time as required by ORS 192.329",
|
||
specificTimeframe: null,
|
||
notes: "Oregon requires 'reasonable time' - no specific deadline"
|
||
},
|
||
GA: {
|
||
name: "Georgia",
|
||
lawName: "Georgia Open Records Act",
|
||
statute: "O.C.G.A. §50-18-70 et seq.",
|
||
responseTime: "within 3 business days as required by O.C.G.A. §50-18-71(b)(1)",
|
||
specificTimeframe: 3,
|
||
notes: "Georgia requires response within 3 business days"
|
||
},
|
||
NC: {
|
||
name: "North Carolina",
|
||
lawName: "North Carolina Public Records Law",
|
||
statute: "N.C. Gen. Stat. §132-1 et seq.",
|
||
responseTime: "within a reasonable time as required by N.C. Gen. Stat. §132-6",
|
||
specificTimeframe: null,
|
||
notes: "North Carolina requires 'reasonable time' - no specific deadline"
|
||
},
|
||
TN: {
|
||
name: "Tennessee",
|
||
lawName: "Tennessee Public Records Act",
|
||
statute: "Tenn. Code Ann. §10-7-503 et seq.",
|
||
responseTime: "within 7 business days as required by Tenn. Code Ann. §10-7-503(a)(2)(B)",
|
||
specificTimeframe: 7,
|
||
notes: "Tennessee requires response within 7 business days"
|
||
},
|
||
MA: {
|
||
name: "Massachusetts",
|
||
lawName: "Massachusetts Public Records Law",
|
||
statute: "Mass. Gen. Laws ch. 66, §10 et seq.",
|
||
responseTime: "within 10 business days as required by Mass. Gen. Laws ch. 66, §10(b)",
|
||
specificTimeframe: 10,
|
||
notes: "Massachusetts requires response within 10 business days"
|
||
},
|
||
PA: {
|
||
name: "Pennsylvania",
|
||
lawName: "Pennsylvania Right-to-Know Law",
|
||
statute: "65 Pa.C.S. §67.101 et seq.",
|
||
responseTime: "within 5 business days as required by 65 Pa.C.S. §67.901",
|
||
specificTimeframe: 5,
|
||
notes: "Pennsylvania requires response within 5 business days"
|
||
},
|
||
OH: {
|
||
name: "Ohio",
|
||
lawName: "Ohio Public Records Act",
|
||
statute: "Ohio Rev. Code §149.43 et seq.",
|
||
responseTime: "within a reasonable time as required by Ohio Rev. Code §149.43(B)",
|
||
specificTimeframe: null,
|
||
notes: "Ohio requires 'reasonable time' - no specific deadline"
|
||
},
|
||
MI: {
|
||
name: "Michigan",
|
||
lawName: "Michigan Freedom of Information Act (FOIA)",
|
||
statute: "MCL 15.231 et seq.",
|
||
responseTime: "within 15 business days as required by MCL 15.235(2)",
|
||
specificTimeframe: 15,
|
||
notes: "Michigan requires response within 15 business days"
|
||
},
|
||
MD: {
|
||
name: "Maryland",
|
||
lawName: "Maryland Public Information Act",
|
||
statute: "Md. Code Ann., Gen. Prov. §4-101 et seq.",
|
||
responseTime: "within 30 days as required by Md. Code Ann., Gen. Prov. §4-203(c)",
|
||
specificTimeframe: 30,
|
||
notes: "Maryland requires response within 30 days"
|
||
},
|
||
NV: {
|
||
name: "Nevada",
|
||
lawName: "Nevada Public Records Law",
|
||
statute: "Nev. Rev. Stat. §239.010 et seq.",
|
||
responseTime: "within 5 business days as required by Nev. Rev. Stat. §239.0107",
|
||
specificTimeframe: 5,
|
||
notes: "Nevada requires response within 5 business days"
|
||
},
|
||
NM: {
|
||
name: "New Mexico",
|
||
lawName: "New Mexico Inspection of Public Records Act",
|
||
statute: "NMSA 1978, §14-2-1 et seq.",
|
||
responseTime: "within 3 business days as required by NMSA 1978, §14-2-8(D)",
|
||
specificTimeframe: 3,
|
||
notes: "New Mexico requires response within 3 business days"
|
||
},
|
||
// Add more states as needed
|
||
};
|
||
|
||
// Security: Input validation and sanitization functions
|
||
|
||
/**
|
||
* Sanitize text input - removes control characters, limits length
|
||
*/
|
||
function sanitizeText(input: string, maxLength: number = 200): string {
|
||
return input
|
||
.trim()
|
||
.replace(/[\x00-\x1F\x7F]/g, '') // Remove control characters
|
||
.slice(0, maxLength); // Enforce length limit
|
||
}
|
||
|
||
/**
|
||
* Sanitize filename - only allow safe characters
|
||
*/
|
||
function sanitizeFilename(input: string): string {
|
||
return input
|
||
.trim()
|
||
.replace(/[^a-zA-Z0-9\s\-_]/g, '') // Only alphanumeric, spaces, hyphens, underscores
|
||
.replace(/\s+/g, '_') // Replace spaces with underscores
|
||
.replace(/_{2,}/g, '_') // Collapse multiple underscores
|
||
.slice(0, 50); // Limit filename length
|
||
}
|
||
|
||
/**
|
||
* Validate email format (basic check)
|
||
*/
|
||
function isValidEmail(email: string): boolean {
|
||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
return emailRegex.test(email) && email.length <= 100;
|
||
}
|
||
|
||
/**
|
||
* Validate ZIP code (US format)
|
||
*/
|
||
function isValidZip(zip: string): boolean {
|
||
const zipRegex = /^\d{5}(-\d{4})?$/;
|
||
return zipRegex.test(zip.trim());
|
||
}
|
||
|
||
/**
|
||
* Validate state code (2 letters)
|
||
*/
|
||
function normalizeStateCode(code: string): string {
|
||
return code.trim().toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
|
||
}
|
||
|
||
/**
|
||
* Validate license plate (alphanumeric, hyphens, spaces)
|
||
*/
|
||
function sanitizeLicensePlate(plate: string): string {
|
||
return plate
|
||
.trim()
|
||
.toUpperCase()
|
||
.replace(/[^A-Z0-9\s\-]/g, '')
|
||
.slice(0, 20);
|
||
}
|
||
|
||
// Helper function to read input from user
|
||
function prompt(question: string): Promise<string> {
|
||
return new Promise((resolve) => {
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout
|
||
});
|
||
|
||
rl.question(question, (answer) => {
|
||
rl.close();
|
||
resolve(answer.trim());
|
||
});
|
||
});
|
||
}
|
||
|
||
// Helper function to get current date in format: Month Day, Year
|
||
function getCurrentDate(): string {
|
||
const date = new Date();
|
||
return date.toLocaleDateString('en-US', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric'
|
||
});
|
||
}
|
||
|
||
// Main interactive function
|
||
async function main() {
|
||
console.log("╔══════════════════════════════════════════════════════════════╗");
|
||
console.log("║ Flock Safety Public Records Request Generator ║");
|
||
console.log("║ Generates state-specific public records requests ║");
|
||
console.log("║ (Security Hardened Version) ║");
|
||
console.log("╚══════════════════════════════════════════════════════════════╝\n");
|
||
|
||
// Get state
|
||
console.log("\nAvailable states:");
|
||
Object.keys(STATE_LAWS).sort().forEach(code => {
|
||
console.log(` ${code} - ${STATE_LAWS[code as keyof typeof STATE_LAWS].name}`);
|
||
});
|
||
|
||
const rawStateCode = await prompt("\nEnter your state code (e.g., AZ, CO, CA): ");
|
||
const stateCode = normalizeStateCode(rawStateCode);
|
||
|
||
if (!STATE_LAWS[stateCode as keyof typeof STATE_LAWS]) {
|
||
console.error(`\n❌ Error: State code "${stateCode}" not found in database.`);
|
||
console.error("Please add your state's information to the STATE_LAWS object in the script,");
|
||
console.error("or use the generic template in foia_request_streamlined.md\n");
|
||
process.exit(1);
|
||
}
|
||
|
||
const stateLaw = STATE_LAWS[stateCode as keyof typeof STATE_LAWS];
|
||
|
||
// Get agency information
|
||
console.log("\n─────────────────────────────────────────────────────────────");
|
||
console.log("Agency Information");
|
||
console.log("─────────────────────────────────────────────────────────────");
|
||
|
||
const agencyName = sanitizeText(await prompt("Agency name (e.g., Denver Police Department): "), 200);
|
||
if (!agencyName) {
|
||
console.error("❌ Error: Agency name is required");
|
||
process.exit(1);
|
||
}
|
||
|
||
const agencyAddress = sanitizeText(await prompt("Agency street address: "), 200);
|
||
const city = sanitizeText(await prompt("City: "), 100);
|
||
const rawZip = await prompt("ZIP code: ");
|
||
|
||
if (!isValidZip(rawZip)) {
|
||
console.error("❌ Error: Invalid ZIP code format (use 12345 or 12345-6789)");
|
||
process.exit(1);
|
||
}
|
||
const zip = rawZip.trim();
|
||
|
||
// Get requester information
|
||
console.log("\n─────────────────────────────────────────────────────────────");
|
||
console.log("Your Information");
|
||
console.log("─────────────────────────────────────────────────────────────");
|
||
|
||
const yourName = sanitizeText(await prompt("Your full name: "), 100);
|
||
if (!yourName) {
|
||
console.error("❌ Error: Your name is required");
|
||
process.exit(1);
|
||
}
|
||
|
||
const yourAddress = sanitizeText(await prompt("Your street address: "), 200);
|
||
const yourCity = sanitizeText(await prompt("Your city: "), 100);
|
||
const yourState = sanitizeText(await prompt("Your state: "), 50);
|
||
|
||
const rawYourZip = await prompt("Your ZIP: ");
|
||
if (!isValidZip(rawYourZip)) {
|
||
console.error("❌ Error: Invalid ZIP code format");
|
||
process.exit(1);
|
||
}
|
||
const yourZip = rawYourZip.trim();
|
||
|
||
const rawEmail = await prompt("Your email: ");
|
||
if (!isValidEmail(rawEmail)) {
|
||
console.error("❌ Error: Invalid email format");
|
||
process.exit(1);
|
||
}
|
||
const yourEmail = rawEmail.trim();
|
||
|
||
const yourPhone = sanitizeText(await prompt("Your phone (optional): "), 20);
|
||
|
||
// Ask about specific requests
|
||
console.log("\n─────────────────────────────────────────────────────────────");
|
||
console.log("Request Customization");
|
||
console.log("─────────────────────────────────────────────────────────────");
|
||
|
||
const includeFootage = await prompt("Request your own vehicle footage? (y/n): ");
|
||
let vehicleInfo = "";
|
||
|
||
if (includeFootage.toLowerCase() === 'y') {
|
||
const rawPlate = await prompt("Your license plate number: ");
|
||
const licensePlate = sanitizeLicensePlate(rawPlate);
|
||
|
||
if (!licensePlate) {
|
||
console.error("❌ Error: Invalid license plate");
|
||
process.exit(1);
|
||
}
|
||
|
||
const vehicleDesc = sanitizeText(await prompt("Vehicle description (e.g., '2020 Honda Civic, blue'): "), 100);
|
||
const dateRange = sanitizeText(await prompt("Date range (e.g., 'January 1-15, 2025'): "), 50);
|
||
|
||
vehicleInfo = `\n\n10. **Vehicle-Specific Request**: All images, footage, and associated data for license plate ${licensePlate} (${vehicleDesc}) for the period of ${dateRange}. This is a request for my own vehicle data.`;
|
||
}
|
||
|
||
const expedited = await prompt("Request expedited processing due to 30-day deletion? (y/n): ");
|
||
|
||
// Generate the letter
|
||
const letter = generateLetter({
|
||
date: getCurrentDate(),
|
||
agencyName,
|
||
agencyAddress,
|
||
city,
|
||
state: stateLaw.name,
|
||
stateCode,
|
||
zip,
|
||
lawName: stateLaw.lawName,
|
||
statute: stateLaw.statute,
|
||
responseTime: stateLaw.responseTime,
|
||
yourName,
|
||
yourAddress,
|
||
yourCity,
|
||
yourState,
|
||
yourZip,
|
||
yourEmail,
|
||
yourPhone,
|
||
vehicleInfo,
|
||
expedited: expedited.toLowerCase() === 'y'
|
||
});
|
||
|
||
// Security: Safe filename generation - restrict to current directory
|
||
const safeAgencyName = sanitizeFilename(agencyName);
|
||
const timestamp = Date.now();
|
||
const filename = `foia_request_${safeAgencyName}_${timestamp}.txt`;
|
||
|
||
// Security: Use path.join to ensure file stays in current directory
|
||
const filepath = join(process.cwd(), filename);
|
||
|
||
// Additional safety check: ensure filepath is still in current directory
|
||
if (!filepath.startsWith(process.cwd())) {
|
||
console.error("❌ Security Error: Invalid file path detected");
|
||
process.exit(1);
|
||
}
|
||
|
||
writeFileSync(filepath, letter);
|
||
|
||
console.log("\n╔══════════════════════════════════════════════════════════════╗");
|
||
console.log("║ ✅ Request Generated Successfully! ║");
|
||
console.log("╚══════════════════════════════════════════════════════════════╝");
|
||
console.log(`\nSaved to: ${filename}`);
|
||
console.log("\n📋 Preview:\n");
|
||
console.log("─────────────────────────────────────────────────────────────");
|
||
console.log(letter);
|
||
console.log("─────────────────────────────────────────────────────────────");
|
||
|
||
console.log("\n💡 Next Steps:");
|
||
console.log(" 1. Review the generated request carefully");
|
||
console.log(" 2. Verify the agency contact information");
|
||
console.log(" 3. Send via email or mail to the agency");
|
||
console.log(" 4. Keep a copy for your records");
|
||
console.log(" 5. Follow up if no response within the legal timeframe");
|
||
|
||
if (expedited) {
|
||
console.log("\n⚠️ IMPORTANT: You requested expedited processing due to");
|
||
console.log(" Flock's 30-day deletion policy. Follow up quickly!");
|
||
}
|
||
|
||
console.log("\n");
|
||
}
|
||
|
||
function generateLetter(params: {
|
||
date: string;
|
||
agencyName: string;
|
||
agencyAddress: string;
|
||
city: string;
|
||
state: string;
|
||
stateCode: string;
|
||
zip: string;
|
||
lawName: string;
|
||
statute: string;
|
||
responseTime: string;
|
||
yourName: string;
|
||
yourAddress: string;
|
||
yourCity: string;
|
||
yourState: string;
|
||
yourZip: string;
|
||
yourEmail: string;
|
||
yourPhone: string;
|
||
vehicleInfo: string;
|
||
expedited: boolean;
|
||
}): string {
|
||
const expeditedText = params.expedited ? `
|
||
|
||
⚠️ TIME-SENSITIVE REQUEST: Due to Flock Safety's 30-day rolling data deletion policy, I request expedited processing of this request to preserve any relevant data that may be automatically deleted during processing.` : '';
|
||
|
||
// Note: All params have already been sanitized before reaching this function
|
||
return `${params.date}
|
||
|
||
${params.agencyName}
|
||
${params.agencyAddress}
|
||
${params.city}, ${params.state} ${params.zip}
|
||
|
||
RE: Public Records Request - Flock Safety System
|
||
|
||
Dear Public Records Custodian:
|
||
|
||
This is a non-commercial public records request made pursuant to the ${params.lawName} (${params.statute}).
|
||
|
||
I am requesting all records related to ${params.agencyName}'s relationship with Flock Group, Inc. (aka Flock Safety, including all subsidiaries), including:
|
||
|
||
1. **Contracts & Agreements**: All contracts, amendments, intergovernmental agreements, and related documents between ${params.agencyName} and Flock Safety or any third parties regarding Flock products/services
|
||
|
||
2. **Financial Records**: Purchase orders, invoices, billing records, budgets, RFPs, bid responses, cost-benefit analyses, and funding sources (including grants)
|
||
|
||
3. **Policies & Procedures**: All policies, guidelines, procedures, or protocols (draft or final) regarding installation, operation, monitoring, data retention, data access, data sharing, or deletion
|
||
|
||
4. **Data Access Records**: Logs of all access requests (internal and external), data-sharing agreements, audit logs, and requests fulfilled or denied
|
||
|
||
5. **Meeting Records**: Minutes, agendas, notes, and presentations from meetings where Flock cameras or services were discussed
|
||
|
||
6. **Legal & Compliance**: Legal opinions, risk assessments, privacy impact assessments, compliance reviews, and related correspondence
|
||
|
||
7. **Technical Documentation**: System specifications, cybersecurity measures, data storage locations, interoperability documentation, and breach notifications
|
||
|
||
8. **Data Retention**: Policies on retention timeframes, deletion procedures, and actual deletion logs
|
||
|
||
9. **Training Materials**: Manuals, training materials, installation records, photographs, videos, and communications (emails, notes, etc.)${params.vehicleInfo}${expeditedText}
|
||
|
||
Please provide records in electronic format if available. If any portions of this request are denied, please provide a written explanation citing the specific legal exemption claimed.
|
||
|
||
I request that you respond ${params.responseTime}.
|
||
|
||
Please send the requested records to:
|
||
|
||
${params.yourName}
|
||
${params.yourAddress}
|
||
${params.yourCity}, ${params.yourState} ${params.yourZip}
|
||
Email: ${params.yourEmail}${params.yourPhone ? `\nPhone: ${params.yourPhone}` : ''}
|
||
|
||
Thank you for your attention to this matter.
|
||
|
||
Sincerely,
|
||
|
||
${params.yourName}`;
|
||
}
|
||
|
||
// Run the script
|
||
main().catch(console.error);
|