feat(security): secret-scanner, commit-guard, llm-redaction, audit-log
- lib/secrets.ts: shared SECRET_PATTERNS + scanSecrets/redact (AWS/GitLab/GitHub/Slack tokens, private keys, JWT, generic credentials) - lib/audit.ts: local JSONL audit (~/.config/pi-kit/audit.jsonl, 600), optional PI_KIT_AUDIT_URL POST; never logs secret values - secret-scanner: block write/edit whose content matches a secret (complements protected-paths) - commit-guard: enforce Conventional Commits on 'git commit -m' + scan message for secrets - llm-redaction: redact secrets from outbound payload via before_provider_request - audit-log: session_start + slash-command usage; wires company.json auditEndpoint into env - config: auditEndpoint (null); package.json: npm test; test/guardrails.test.ts (16 cases, all pass) Verified: all extensions load via pi (lib/ not loaded as extensions); npm test green.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Shared local audit log used by the guardrail extensions.
|
||||
*
|
||||
* WHAT: appends JSONL events to ~/.config/pi-kit/audit.jsonl (chmod 600).
|
||||
* Never logs secret values — only pattern names, paths, command shape.
|
||||
* If PI_KIT_AUDIT_URL is set (or company.json auditEndpoint, wired by
|
||||
* audit-log.ts into that env var), each event is also POSTed best-effort.
|
||||
*
|
||||
* DISABLE: remove the audit-log.ts extension, or set PI_KIT_AUDIT_OFF=1.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const DIR = path.join(os.homedir(), ".config", "pi-kit");
|
||||
const FILE = path.join(DIR, "audit.jsonl");
|
||||
const POST_TIMEOUT_MS = 1500;
|
||||
|
||||
export interface AuditEntry {
|
||||
kind: string; // e.g. "guardrail_block", "command", "session_start"
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Append an audit entry locally and optionally forward it to an endpoint. */
|
||||
export function audit(entry: AuditEntry): void {
|
||||
if (process.env.PI_KIT_AUDIT_OFF === "1") return;
|
||||
const line = JSON.stringify({ t: new Date().toISOString(), ...entry });
|
||||
|
||||
try {
|
||||
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
||||
fs.appendFileSync(FILE, `${line}\n`, { mode: 0o600 });
|
||||
} catch {
|
||||
// Auditing must never break the agent; swallow local write errors.
|
||||
}
|
||||
|
||||
const url = process.env.PI_KIT_AUDIT_URL;
|
||||
if (url) {
|
||||
// Best-effort, fire-and-forget. Never awaited; failures are ignored.
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: line,
|
||||
signal: AbortSignal.timeout(POST_TIMEOUT_MS),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Shared secret detection/redaction used by secret-scanner, commit-guard and
|
||||
* llm-redaction extensions.
|
||||
*
|
||||
* CONFIGURE: add entries to SECRET_PATTERNS below. Each pattern has a `name`
|
||||
* (shown in warnings/audit, never the secret itself) and a `re` (no global
|
||||
* flag — the global flag is added on demand for redaction).
|
||||
*/
|
||||
|
||||
export interface SecretPattern {
|
||||
name: string;
|
||||
re: RegExp;
|
||||
}
|
||||
|
||||
// Patterns are deliberately conservative to limit false positives. The user
|
||||
// still confirms interactively, so a rare false positive is cheap.
|
||||
export const SECRET_PATTERNS: SecretPattern[] = [
|
||||
{ name: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/ },
|
||||
{ name: "gitlab-pat", re: /\bglpat-[0-9A-Za-z_-]{20,}\b/ },
|
||||
{ name: "github-pat", re: /\bghp_[0-9A-Za-z]{36}\b/ },
|
||||
{ name: "github-fine-grained-pat", re: /\bgithub_pat_[0-9A-Za-z_]{22,}\b/ },
|
||||
{ name: "slack-token", re: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/ },
|
||||
{ name: "private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/ },
|
||||
{ name: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ },
|
||||
// Generic "secret: value" / "token=value" assignments with a non-trivial value.
|
||||
{ name: "generic-credential", re: /\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["']?[^\s"']{8,}["']?/i },
|
||||
];
|
||||
|
||||
/** Return the names of secret patterns found in `text` (empty if none). */
|
||||
export function scanSecrets(text: string): string[] {
|
||||
if (!text) return [];
|
||||
const hits: string[] = [];
|
||||
for (const p of SECRET_PATTERNS) {
|
||||
// Use a non-stateful copy (no global flag) to avoid lastIndex surprises.
|
||||
if (p.re.test(text)) hits.push(p.name);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Replace every secret occurrence with a labelled placeholder. */
|
||||
export function redact(text: string): string {
|
||||
if (!text) return text;
|
||||
let out = text;
|
||||
for (const p of SECRET_PATTERNS) {
|
||||
const g = new RegExp(p.re.source, p.re.flags.includes("g") ? p.re.flags : `${p.re.flags}g`);
|
||||
out = out.replace(g, `«REDACTED:${p.name}»`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user