diff --git a/plugins/acuvity/helper.ts b/plugins/acuvity/helper.ts new file mode 100644 index 00000000..07beae62 --- /dev/null +++ b/plugins/acuvity/helper.ts @@ -0,0 +1,198 @@ +import * as jose from 'jose'; +import { Extraction, Textualdetection, TextualdetectionType } from './model.js'; + +export interface GuardResult { + matched: boolean; + guardName: GuardName; + threshold: string; + actualValue: number; + matchCount: number; + matchValues: string[]; +} + +export class GuardName { + private constructor(private readonly name: string) {} + + // Static instances + public static readonly PROMPT_INJECTION = new GuardName('PROMPT_INJECTION'); + public static readonly JAIL_BREAK = new GuardName('JAIL_BREAK'); + public static readonly MALICIOUS_URL = new GuardName('MALICIOUS_URL'); + public static readonly TOXIC = new GuardName('TOXIC'); + public static readonly BIASED = new GuardName('BIASED'); + public static readonly HARMFUL_CONTENT = new GuardName('HARMFUL_CONTENT'); + public static readonly LANGUAGE = new GuardName('LANGUAGE'); + public static readonly PII_DETECTOR = new GuardName('PII_DETECTOR'); + public static readonly SECRETS_DETECTOR = new GuardName('SECRETS_DETECTOR'); + public static readonly KEYWORD_DETECTOR = new GuardName('KEYWORD_DETECTOR'); + + public toString(): string { + return this.name; + } + + public equals(other: GuardName): boolean { + return this.name === other.name; + } +} + +export function getApexUrlFromToken(token: string): string | null { + try { + const decodedToken = jose.decodeJwt(token); + return decodedToken?.opaque?.['apex-url'] || null; + } catch (error) { + return null; + } +} + +export class ResponseHelper { + /** + * Evaluates a check condition using guard name and threshold. + * + * @param extraction - Provides all the extractions based on the detection engine + * @param guardName - The name of the guard to evaluate + * @param threshold - The threshold value to compare against + * @param matchName - The match name for the guard + * @returns GuardResult with TRUE if condition met, False if not met + */ + public evaluate( + extraction: Extraction, + guardName: GuardName, + threshold: number, + matchName?: string + ): GuardResult { + let exists = false; + let value = 0.0; + let matchCount = 0; + let matchValues: string[] = []; + + try { + if ( + guardName.equals(GuardName.PROMPT_INJECTION) || + guardName.equals(GuardName.JAIL_BREAK) || + guardName.equals(GuardName.MALICIOUS_URL) + ) { + [exists, value] = this.getGuardValue( + extraction.exploits, + guardName.toString() + ); + } else if ( + guardName.equals(GuardName.TOXIC) || + guardName.equals(GuardName.BIASED) || + guardName.equals(GuardName.HARMFUL_CONTENT) + ) { + const prefix = `content/${guardName}`; + [exists, value] = this.getGuardValue(extraction.topics, prefix); + } else if (guardName.equals(GuardName.LANGUAGE)) { + if (matchName) { + [exists, value] = this.getGuardValue(extraction.languages, matchName); + } else if (extraction.languages) { + exists = Object.keys(extraction.languages).length > 0; + value = 1.0; + } + } else if (guardName.equals(GuardName.PII_DETECTOR)) { + [exists, value, matchCount, matchValues] = this.getTextDetections( + extraction.piIs, + threshold, + TextualdetectionType.Pii, + extraction.detections, + matchName + ); + } else if (guardName.equals(GuardName.SECRETS_DETECTOR)) { + [exists, value, matchCount, matchValues] = this.getTextDetections( + extraction.secrets, + threshold, + TextualdetectionType.Secret, + extraction.detections, + matchName + ); + } else if (guardName.equals(GuardName.KEYWORD_DETECTOR)) { + [exists, value, matchCount, matchValues] = this.getTextDetections( + extraction.keywords, + threshold, + TextualdetectionType.Keyword, + extraction.detections, + matchName + ); + } + + const matched = exists && value >= threshold; + + return { + matched, + guardName, + threshold: threshold.toString(), + actualValue: value, + matchCount, + matchValues, + }; + } catch (e) { + throw new Error( + `Error in evaluation: ${e instanceof Error ? e.message : String(e)}` + ); + } + } + + /** + * Gets the guard value from a lookup object + */ + private getGuardValue( + lookup: Record | undefined, + key: string + ): [boolean, number] { + if (!lookup || !(key in lookup)) { + return [false, 0.0]; + } + return [true, lookup[key]]; + } + + /** + * Gets text detection values + */ + private getTextDetections( + lookup: Record | undefined, + threshold: number, + detectionType: TextualdetectionType, + detections: Textualdetection[] | undefined, + matchName?: string + ): [boolean, number, number, string[]] { + if (matchName) { + if (!detections) { + return [false, 0.0, 0, []]; + } + + const textMatches = detections + .filter( + (d): d is Textualdetection & { score: number } => + d.type === detectionType && + d.name === matchName && + d.score !== null && + typeof d.score === 'number' && + d.score >= threshold + ) + .map((d) => d.score); + + const count = textMatches.length; + + if (count === 0 && lookup && matchName in lookup) { + const lookupValue = lookup[matchName]; + if (typeof lookupValue === 'number') { + return [true, lookupValue, 1, [matchName]]; + } + } + + if (count === 0) { + return [false, 0.0, 0, []]; + } + + const maxScore = Math.max(...textMatches); + return [true, maxScore, count, [matchName]]; + } + + const exists = !!lookup && Object.keys(lookup).length > 0; + return [ + exists, + exists ? 1.0 : 0.0, + lookup ? Object.keys(lookup).length : 0, + lookup ? Object.keys(lookup) : [], + ]; + } +} diff --git a/plugins/acuvity/manifest.json b/plugins/acuvity/manifest.json new file mode 100644 index 00000000..cee9551a --- /dev/null +++ b/plugins/acuvity/manifest.json @@ -0,0 +1,324 @@ +{ + "id": "acuvity", + "description": "acuvity Plugin", + "credentials": { + "type": "object", + "properties": { + "apiKey": { + "type": "string", + "label": "API Key", + "description": "Find your API key in the acuvity dashboard", + "encrypted": true + } + }, + "required": ["apiKey"] + }, + "functions": [ + { + "name": "Acuvity", + "id": "Acuvity", + "type": "guardrail", + "description": [ + { + "type": "subHeading", + "text": "Comprehensive content safety and security checks" + } + ], + "parameters": { + "type": "object", + "properties": { + "prompt_injection": { + "type": "object", + "label": "Prompt Injection Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "multipleOf": 0.01, + "default": 0.5 + } + } + }, + "toxic": { + "type": "object", + "label": "Toxicity Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "multipleOf": 0.01, + "default": 0.5 + } + } + }, + "jail_break": { + "type": "object", + "label": "Jailbreak Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "multipleOf": 0.01, + "default": 0.5 + } + } + }, + "malicious_url": { + "type": "object", + "label": "Malicious URL Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "multipleOf": 0.01, + "default": 0.5 + } + } + }, + "biased": { + "type": "object", + "label": "Bias Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "multipleOf": 0.01, + "default": 0.5 + } + } + }, + "harmful": { + "type": "object", + "label": "Harmful Content Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "threshold": { + "type": "number", + "minimum": 0, + "maximum": 1, + "multipleOf": 0.01, + "default": 0.5 + } + } + }, + "language": { + "type": "object", + "label": "Language Check", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "languagevals": { + "type": "string", + "enum": [ + "eng_Latn", + "zho_Hans", + "spa_Latn", + "ara_Arab", + "por_Latn", + "ind_Latn", + "vie_Latn" + ], + "enumNames": [ + "English", + "Chinese (Simplified)", + "Spanish", + "Modern Standard Arabic", + "Portuguese", + "Indonesian", + "Vietnamese" + ], + "default": "eng_Latn" + } + } + }, + "pii": { + "type": "object", + "label": "PII Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "redact": { + "type": "boolean", + "default": false + }, + "categories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "EMAIL_ADDRESS", + "PHONE_NUMBER", + "LOCATION_ADDRESS", + "NAME", + "IP_ADDRESS", + "CREDIT_CARD", + "SSN" + ] + }, + "default": [ + "EMAIL_ADDRESS", + "PHONE_NUMBER", + "CREDIT_CARD", + "SSN" + ] + } + } + }, + "secrets": { + "type": "object", + "label": "PII Detection", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "redact": { + "type": "boolean", + "default": false + }, + "categories": { + "type": "array", + "label": "Secret Categories", + "description": [ + { + "type": "subHeading", + "text": "Select the types of secrets that should be detected in the content." + } + ], + "items": { + "type": "string", + "enum": [ + "credentials", + "adafruit", + "alibaba", + "anthropic", + "apideck", + "apify", + "atlassian", + "aws_secret_key", + "buildkite", + "checkout", + "clickuppersonal", + "contentfulpersonalaccess", + "database_url_with_credentials", + "databricks", + "denodeploy", + "dfuse", + "digitalocean", + "discord_webhook", + "docker_hub", + "doppler", + "dropbox", + "endorlabs", + "fleetbase", + "flutterwave", + "frameio", + "freshdesk", + "fullstory", + "github", + "gitlab", + "gocardless", + "google_api", + "grafana", + "groq", + "huggingface", + "intra42", + "jwt", + "klaviyo", + "launchdarkly", + "linearapi", + "locationiq", + "mailchimp", + "mailgun", + "mapbox", + "maxmind", + "microsoft_teams_webhook", + "nightfall", + "notion", + "npm", + "openai", + "pagarme", + "paystack", + "planetscale", + "planetscaledb", + "portainer", + "posthog", + "postman", + "prefect", + "private_key", + "pubnub_publish", + "pubnub_subscribe", + "pulumi", + "ramp", + "razorpay", + "readme", + "rechargepayments", + "replicate", + "rubygems", + "salesforce", + "sendgrid", + "sendinblue", + "shopify", + "slack_access", + "slack_bot", + "slack_refresh", + "slack_user", + "slack_webhook", + "slack_workflow_webhook", + "sourcegraph", + "sourcegraphcody", + "squareapp", + "squareup", + "stripe", + "supabase", + "tailscale", + "tines_webhook", + "trufflehog", + "twilio", + "ubidots", + "voiceflow", + "web_url_with_credentials", + "zapierwebhook" + ] + } + } + }, + "required": ["categories"] + } + } + } + } + ] +} diff --git a/plugins/acuvity/model.ts b/plugins/acuvity/model.ts new file mode 100644 index 00000000..1e1c8ca3 --- /dev/null +++ b/plugins/acuvity/model.ts @@ -0,0 +1,141 @@ +export type ClosedEnum = T[keyof T]; + +export type Modality = { + /** + * The group of data. + */ + group: string; + /** + * The type of data. + */ + type: string; +}; + +/** + * The type of detection. + */ +export const TextualdetectionType = { + Keyword: 'Keyword', + Pii: 'PII', + Secret: 'Secret', +} as const; + +/** + * The type of detection. + */ +export type TextualdetectionType = ClosedEnum; + +/** + * Represents a textual detection done by policy. + */ +export type Textualdetection = { + /** + * The end position of the detection in the original data. + */ + end?: number | undefined; + /** + * The key that is used in the name's place, If empty, a sequence of X's are used. + */ + key?: string | undefined; + /** + * The name of the detection. + */ + name?: string | undefined; + /** + * If true this detection has been redacted. + */ + redacted?: boolean | undefined; + /** + * The end position of the detection in the redacted data. + */ + redactedEnd?: number | undefined; + /** + * The start position of the detection in the redacted data. + */ + redactedStart?: number | undefined; + /** + * The confidence score of the detection. + */ + score?: number | undefined; + /** + * The start position of the detection in the original data. + */ + start?: number | undefined; + /** + * The type of detection. + */ + type?: TextualdetectionType | undefined; +}; + +export type Extraction = { + /** + * The PIIs found during classification. + */ + piIs?: { [k: string]: number } | undefined; + /** + * Annotations attached to the extraction. + */ + annotations?: { [k: string]: string } | undefined; + /** + * The level of general confidentiality of the input. + */ + confidentiality?: number | undefined; + /** + * The data extracted. + */ + data?: string | undefined; + /** + * The detections found while applying policies. + */ + detections?: Array | undefined; + /** + * The various exploits attempts. + */ + exploits?: { [k: string]: number } | undefined; + /** + * The hash of the extraction. + */ + hash?: string | undefined; + /** + * The estimated intent embodied into the text. + */ + intent?: { [k: string]: number } | undefined; + /** + * If true, this extraction is for internal use only. This can be used by agentic + * + * @remarks + * systems to mark an extraction as internal only as opposed to user facing. + */ + internal?: boolean | undefined; + /** + * The keywords found during classification. + */ + keywords?: { [k: string]: number } | undefined; + /** + * A means of distinguishing what was extracted, such as prompt, input file or + * + * @remarks + * code. + */ + label?: string | undefined; + /** + * The language of the classification. + */ + languages?: { [k: string]: number } | undefined; + /** + * The modalities of data detected in the data. + */ + modalities?: Array | undefined; + /** + * The level of general organization relevance of the input. + */ + relevance?: number | undefined; + /** + * The secrets found during classification. + */ + secrets?: { [k: string]: number } | undefined; + /** + * The topic of the classification. + */ + topics?: { [k: string]: number } | undefined; +}; diff --git a/plugins/acuvity/scan.ts b/plugins/acuvity/scan.ts new file mode 100644 index 00000000..b5acc619 --- /dev/null +++ b/plugins/acuvity/scan.ts @@ -0,0 +1,183 @@ +import { + HookEventType, + PluginContext, + PluginHandler, + PluginParameters, +} from '../types'; +import { + getApexUrlFromToken, + ResponseHelper, + GuardName, + GuardResult, +} from './helper'; +import { getText, post } from '../utils'; + +export const postAcuvityScan = async ( + base_url: string, + credentials: any, + data: any +) => { + const options = { + headers: { + Authorization: 'Bearer ' + credentials.apiKey, + }, + }; + return post(base_url, data, options); +}; + +export const handler: PluginHandler = async ( + context: PluginContext, + parameters: PluginParameters, + eventType: HookEventType +) => { + let error = null; + let verdict = false; + let data = null; + + if (!parameters.credentials) { + throw new Error('acuvity api key not given'); + } + + const text = getText(context, eventType); + + let token = parameters.credentials.apiKey; + let base_url = getApexUrlFromToken(token); + + if (!base_url) { + throw new Error('acuvity base url not given'); + } + + const result: any = await postAcuvityScan( + base_url, + parameters.credentials, + text + ); + + const extraction = result.extractions[0]; + + const responseHelper = new ResponseHelper(); + + // Assuming parameters is loaded from manifest.json + const results = evaluateAllParameters(extraction, parameters, responseHelper); + + // Process results + results.forEach(({ parameter, result }) => { + if (result.matched) { + console.log(`${parameter} check failed with value ${result.actualValue}`); + verdict = true; + } + }); + + return { error, verdict, data }; +}; + +function evaluateAllParameters( + extraction: any, + parameters: PluginParameters, + responseHelper: ResponseHelper +): Array<{ parameter: string; result: GuardResult }> { + const results: Array<{ parameter: string; result: GuardResult }> = []; + + // Check prompt injection + if (parameters.prompt_injection.enabled) { + results.push({ + parameter: 'prompt_injection', + result: responseHelper.evaluate( + extraction, + GuardName.PROMPT_INJECTION, + parameters.prompt_injection.threshold || 0.5 + ), + }); + } + + // Check toxic content + if (parameters.toxic.enabled) { + results.push({ + parameter: 'toxic', + result: responseHelper.evaluate( + extraction, + GuardName.TOXIC, + parameters.toxic.threshold || 0.5 + ), + }); + } + + // Check jailbreak + if (parameters.jail_break.enabled) { + results.push({ + parameter: 'jail_break', + result: responseHelper.evaluate( + extraction, + GuardName.JAIL_BREAK, + parameters.jail_break.threshold || 0.5 + ), + }); + } + + // Check malicious URL + if (parameters.malicious_url.enabled) { + results.push({ + parameter: 'malicious_url', + result: responseHelper.evaluate( + extraction, + GuardName.MALICIOUS_URL, + parameters.malicious_url.threshold || 0.5 + ), + }); + } + + // Check bias + if (parameters.biased.enabled) { + results.push({ + parameter: 'biased', + result: responseHelper.evaluate( + extraction, + GuardName.BIASED, + parameters.biased.threshold || 0.5 + ), + }); + } + + // Check harmful content + if (parameters.harmful.enabled) { + results.push({ + parameter: 'harmful', + result: responseHelper.evaluate( + extraction, + GuardName.HARMFUL_CONTENT, + parameters.harmful.threshold || 0.5 + ), + }); + } + + // Check language + if (parameters.language.enabled && parameters.language.languagevals) { + results.push({ + parameter: 'language', + result: responseHelper.evaluate( + extraction, + GuardName.LANGUAGE, + 0.5, // Language check typically uses a fixed threshold + parameters.language.languagevals + ), + }); + } + + // Check PII + if (parameters.pii.enabled && parameters.pii.categories) { + // Iterate through each PII category + for (const category of parameters.pii.categories) { + results.push({ + parameter: `pii_${category.toLowerCase()}`, + result: responseHelper.evaluate( + extraction, + GuardName.PII_DETECTOR, + 0.5, // PII typically uses a fixed threshold + category.toLowerCase() + ), + }); + } + } + + return results; +}