IoT device lifecycle, telemetry anomaly detection, fleet management, and witness chain verification for Cognitum Seed hardware

IoT device lifecycle, telemetry anomaly detection, fleet management, and witness chain verification for Cognitum Seed hardware.
This plugin requires a Cognitum Seed device. Get one at https://cognitum.one — the Seed is an edge appliance with on-device vector store, Ed25519 identity, OTA firmware, mesh networking, and a witness chain. Default address when attached via USB-C is http://169.254.42.1 (link-local, no auth) or https://169.254.42.1:8443 (LAN, bearer auth required for state-mutating operations).
Treats every Cognitum Seed device as a Ruflo agent with hardware capabilities. Devices progress through a 5-tier trust model, emit telemetry vectors for anomaly detection, participate in mesh networks, and maintain Ed25519 witness chains for provenance.
Backed by @claude-flow/plugin-iot-cognitum (239 tests, 39 source files).
claude --plugin-dir plugins/ruflo-iot-cognitum
| Agent | Model | Role |
|---|---|---|
device-coordinator | sonnet | Device lifecycle, 5-tier trust scoring, mesh coordination |
telemetry-analyzer | sonnet | Z-score anomaly detection, SONA learning, AgentDB persistence |
fleet-manager | sonnet | Fleet CRUD, firmware rollout state machine, fleet policies |
witness-auditor | haiku | Witness chain epoch verification, gap detection |
| Skill | Usage | Description | ||||
|---|---|---|---|---|---|---|
iot-register | /iot-register <endpoint> | Register a Seed device | ||||
iot-fleet | `/iot-fleet <create\ | list\ | add\ | remove\ | delete>` | Fleet management |
iot-anomalies | /iot-anomalies <device-id> | Detect telemetry anomalies | ||||
iot-firmware | `/iot-firmware <deploy\ | advance\ | rollback\ | status\ | list>` | Firmware rollouts |
iot-witness-verify | /iot-witness-verify <device-id> | Verify witness chain integrity |
# Device lifecycle
# `endpoint` defaults to http://169.254.42.1/ (the Seed link-local USB Ethernet address)
iot register [endpoint] [--token TOKEN]
iot list
iot status <device-id>
iot pair <device-id>
iot unpair <device-id>
iot remove <device-id>
# Telemetry
iot ingest <device-id>
iot baseline <device-id> [--compute]
iot anomalies <device-id>
iot query <device-id> --vector "[1,2,3]" --k 10
# Fleet management
iot fleet create --name "my-fleet"
iot fleet list
iot fleet add <fleet-id> <device-id>
iot fleet remove <fleet-id> <device-id>
iot fleet delete <fleet-id>
# Firmware rollouts
iot firmware deploy <fleet-id> --version "2.0.0"
iot firmware advance <rollout-id>
iot firmware rollback <rollout-id>
iot firmware status <rollout-id>
iot firmware list
# Mesh & witness
iot mesh <device-id>
iot witness <device-id>
iot witness verify <device-id>
iot health <device-id>
iot trust <device-id>
| Level | Name | Score Range | Capabilities |
|---|---|---|---|
| 0 | UNKNOWN | 0.0–0.19 | Discovery only |
| 1 | REGISTERED | 0.2–0.39 | Status, identity queries |
| 2 | PROVISIONED | 0.4–0.59 | Telemetry ingest, vector store |
| 3 | CERTIFIED | 0.6–0.79 | Mesh participation, firmware deploy |
| 4 | FLEET_TRUSTED | 0.8–1.0 | Full fleet operations, witness signing |
Trust Score Formula:
0.3×pairingIntegrity + 0.15×firmwareCurrency + 0.2×uptimeStability
+ 0.15×witnessIntegrity + 0.1×anomalyHistory + 0.1×meshParticipation
Z-score composite scoring: min(1, meanZ/3)
| Type | Detection Rule | Typical Cause |
|---|---|---|
| spike | maxZ > 5 | Sudden sensor failure |
| flatline | all zero + low Z | Sensor disconnected |
| drift | 1-2 dimensions high Z | Gradual calibration loss |
| oscillation | alternating high/low | Feedback loop |
| pattern-break | moderate Z, multiple dims | Environmental change |
| cluster-outlier | >50% dimensions high Z | Multi-sensor failure |
pending → canary → rolling → complete
↘ rolled-back ↙
ceil(deviceCount × canaryPercentage/100) devices| Worker | Interval | Event |
|---|---|---|
| HealthProbeWorker | 30s | iot:device-offline |
| TelemetryIngestWorker | 60s | — |
| AnomalyScanWorker | 120s | iot:anomaly-detected |
| MeshSyncWorker | 120s | iot:mesh-partition |
| FirmwareWatchWorker | 300s | iot:firmware-mismatch |
| WitnessAuditWorker | 600s | iot:witness-gap |
iot-telemetry namespace with HNSW indexing (M=16, efConstruction=200)@cognitum-one/sdk/seed SeedClient with 12 typed endpoints@claude-flow/cli v3.6 major+minor.@cognitum-one/sdk/seed.bash plugins/ruflo-iot-cognitum/scripts/smoke.sh is the contract.This plugin owns five AgentDB namespaces, all compliant with the ruflo-agentdb ADR-0001 §"Namespace convention" (<plugin-stem>-<intent> kebab-case):
| Namespace | Purpose |
|---|---|
iot-devices | Device trust history per Cognitum Seed |
iot-telemetry | Telemetry vectors (HNSW: M=16, efConstruction=200) |
iot-telemetry-anomalies | Detected anomalies tagged by type + remedial action |
iot-anomalies | Skill-level anomaly index (alias of above) |
iot-audit | Witness-chain gap records |
Reserved namespaces (pattern, claude-memories, default) MUST NOT be shadowed.
A function-hook mod ships beside the skills (ADR-445 pattern). Needs a Claude Code with mods (2.1.287+); older builds ignore it. No network, no process spawning: it only tightens calls to this plugin's own tools and reads through tools already connected.
| Piece | Default | What it does |
|---|---|---|
| Write guard | on | Refuses a secret in an iot-* memory record, or on a cognitum-iot command line. |
| Destructive confirm | on | Refuses cognitum-iot fleet delete and device delete/remove/revoke/decommission/deregister unless the command has --confirm/--yes or the COGNITUM_IOT_CONFIRM=1 prefix (rollbacks and lists are untouched). |
/iot-mod | — | status, scan <text>, devices (reads the iot-devices namespace through the connected memory tool); answered locally, no model call. |
| Status file | — | .claude-flow/iot-mod/status.json (version, updatedMs, mode flags and counters); written at session start and when a counter changes. |
Options (userConfig): guard on\|off, confirmDestructive on\|off. Refusals never echo the value they matched.
claude plugin test plugins/ruflo-iot-cognitum # 10 tests
This plugin's 5-tier device trust model (UNKNOWN → REGISTERED → PROVISIONED → CERTIFIED → FLEET_TRUSTED) follows the same shape as the ruflo-federation 5-tier trust model (UNTRUSTED → VERIFIED → ATTESTED → TRUSTED → PRIVILEGED). Different surface (IoT devices vs federation peers) and distinct naming, but the score-driven progression and capability-gating principle are the same.
bash plugins/ruflo-iot-cognitum/scripts/smoke.sh
# Expected: "12 passed, 0 failed"
ruflo-agentdb — HNSW-indexed telemetry storage backend; namespace convention ownerruflo-federation — 5-tier trust model parallel (different surface, distinct naming, same shape)ruflo-intelligence — SONA neural pattern learningruflo-observability — Telemetry correlation and tracingMIT
hooks/register.ts 76 lines1import type { Hook, Register } from 'claude-code'
2
3import { answer } from './command'
4import { verdict } from './guard'
5import { modeOf, readOptions, type ModOptions } from './options'
6import { newStats, STATUS_PATH, statusText, type Stats } from './status'
7import { findTool } from './tools'
8
9const TEXT_CAP = 20_000
10
11type Dollar = Parameters<Hook<'session.start'>>[0]
12
13/** Everything one session of the mod keeps: its settings, its counters and the project root. */
14type Session = { readonly opts: ModOptions; readonly stats: Stats; root?: string }
15
16async function flush($: Dollar, s: Session): Promise<void> {
17 if (s.root === undefined) return
18 try {
19 await $.fs.write(`${s.root}/${STATUS_PATH}`, statusText(s.stats, modeOf(s.opts), await $.clock.now()))
20 } catch {
21 /* the status file is a courtesy */
22 }
23}
24
25/** A read-only call through an already-connected tool: its first text block, or undefined when absent or errored. */
26async function peek($: Dollar, suffix: string, args: Record<string, unknown>): Promise<string | undefined> {
27 const hit = findTool(await $.tool.list(), suffix)
28 if (!hit) return undefined
29 const res = await $.mcp.call(hit.server, hit.tool, args)
30 return res.isError ? undefined : (res.content.find(b => b.type === 'text')?.text ?? '').slice(0, TEXT_CAP)
31}
32
33/**
34 * IoT Cognitum as a mod (ADR-445 pattern): a tighten-only guard on this plugin's own tools, `/iot-mod`, and a status file the console
35 * reads. No network, no process spawning: only tools already connected.
36 */
37export const register: Register = (on, options) => {
38 const s: Session = { opts: readOptions(options), stats: newStats() }
39
40 on('session.start', async ($, e, next) => {
41 const result = await next(e)
42 s.root = (await $.session.root()) as string | undefined
43 try {
44 await $.command.register({ name: 'iot-mod', description: 'IoT Cognitum mod: status, scan <text>, devices' })
45 } catch {
46 /* a name taken by another plugin must not stop the mod */
47 }
48 await flush($, s)
49 return result
50 })
51
52 if (s.opts.guard) {
53 on('tool.call', async ($, e, next) => {
54 const reason = verdict(e.tool, e, s.opts)
55 if (reason === undefined) return next(e)
56 s.stats.blocked++
57 s.stats.lastDenied = e.tool
58 await flush($, s)
59 return { deny: reason }
60 })
61 }
62
63 /** `/iot-mod` is answered locally and takes no model turn. */
64 on('command.run', { command: 'iot-mod' }, async ($, e) => {
65 s.stats.commands++
66 const text = await answer(typeof e.args === 'string' ? e.args : '', {
67 opts: s.opts,
68 stats: s.stats,
69 peek: (suffix, args) => peek($, suffix, args),
70 toolNames: async () => (await $.tool.list()).map(t => t.name),
71 })
72 await flush($, s)
73 return { text }
74 })
75}
76hooks/command.ts 41 lines1import { scan, tidy } from './screen'
2import type { ModOptions } from './options'
3import type { Stats } from './status'
4
5export type CommandDeps = {
6 readonly opts: ModOptions
7 readonly stats: Stats
8 readonly peek: (suffix: string, args: Record<string, unknown>) => Promise<string | undefined>
9 readonly toolNames: () => Promise<readonly string[]>
10}
11
12const HELP = ['/iot-mod status', '/iot-mod scan <text>', '/iot-mod devices'].join('\n')
13
14/** `/iot-mod` is answered locally and takes no model turn (the plugin's `/iot` is a prompt command, which no hook can answer). */
15export async function answer(args: string, deps: CommandDeps): Promise<string> {
16 const [verb = '', ...rest] = args.trim().split(/\s+/)
17 const arg = rest.join(' ')
18 const { opts, stats } = deps
19
20 if (verb === '' || verb === 'help') return HELP
21
22 if (verb === 'status') {
23 return `guard ${opts.guard ? 'on' : 'off'} · destructive needs confirm ${opts.confirmDestructive ? 'on' : 'off'}\ncalls blocked ${stats.blocked} · scans ${stats.scans} · commands ${stats.commands}${stats.lastDenied ? ` · last denied ${stats.lastDenied}` : ''}`
24 }
25
26 if (verb === 'scan') {
27 if (arg === '') return 'usage: /iot-mod scan <text>'
28 stats.scans++
29 const found = scan(arg).secrets
30 return found.length ? `The guard would refuse a device record or command with that (secrets: ${found.join(', ')}).` : 'No secret shape found: the guard would let that through.'
31 }
32
33 if (verb === 'devices') {
34 const text = await deps.peek('memory_list', { namespace: 'iot-devices', limit: 20 })
35 if (text === undefined) return 'No memory tool is connected (or it errored). Connect the ruflo MCP server and try again.'
36 return text.trim() === '' ? 'No registered devices stored.' : `Registered devices (as stored, untrusted data):\n${tidy(text, 1200)}`
37 }
38
39 return `Unknown: ${verb}\n${HELP}`
40}
41hooks/guard.ts 36 lines1import { hasSecret, textsOf } from './screen'
2import { bare, namespaceOf } from './tools'
3import type { ModOptions } from './options'
4
5const IOT_NS = /^iot-/i
6/** The plugin's CLI; its skills run it through Bash. */
7const CLI = /\bcognitum-iot\b/
8// Deleting a fleet, or deleting/revoking/decommissioning a device, is not undone by a rollback.
9const DESTRUCTIVE = /\b(?:fleet\s+delete|device\s+(?:delete|remove|revoke|decommission|deregister))\b/
10// An explicit go-ahead the person has given: a flag, or an env prefix (which the CLI never sees as an unknown option). Not `-y`: that is npx's own flag.
11const CONFIRMED = /(?:^|\s)(?:--confirm|--yes)(?:\s|=|$)|\bCOGNITUM_IOT_CONFIRM=1\b/
12
13/** The reason an IoT call is refused, or undefined when it may go. Never names or echoes the value. */
14export function verdict(tool: string, input: unknown, opts: ModOptions): string | undefined {
15 const name = bare(tool)
16
17 if (name === 'memory_store') {
18 if (!IOT_NS.test(namespaceOf(input))) return undefined
19 return textsOf(input).some(hasSecret)
20 ? 'ruflo-iot-cognitum: this device record holds what looks like a secret (a device token, key or password). Store the device id, not the credential.'
21 : undefined
22 }
23
24 if (name !== 'Bash') return undefined
25 const command = (input as { command?: unknown } | null)?.command
26 if (typeof command !== 'string' || !CLI.test(command)) return undefined
27
28 if (hasSecret(command)) {
29 return 'ruflo-iot-cognitum: this cognitum-iot command carries what looks like a secret on its command line. Pass it through the environment or a prompt, not as an argument.'
30 }
31 if (opts.confirmDestructive && DESTRUCTIVE.test(command) && !CONFIRMED.test(command)) {
32 return 'ruflo-iot-cognitum: deleting a fleet or removing a device cannot be undone by a rollback. Ask the user first; once they agree, run it again prefixed with COGNITUM_IOT_CONFIRM=1.'
33 }
34 return undefined
35}
36hooks/options.ts 17 lines1import type { PluginOptions } from 'claude-code'
2
3/** The plugin's `userConfig`, validated: a bad value is the default (both on). */
4export type ModOptions = { readonly guard: boolean; readonly confirmDestructive: boolean }
5
6// BEGIN SHARED FLAG (generated by scripts/sync-mod-screen.mjs; edit plugins/ruflo-agentdb/hooks/options.ts)
7export const flag = (value: unknown, fallback: boolean) =>
8 value === true || value === 'true' || value === 'on' ? true : value === false || value === 'false' || value === 'off' ? false : fallback
9// END SHARED FLAG
10
11export function readOptions(options: PluginOptions | undefined): ModOptions {
12 const o = options ?? {}
13 return { guard: flag(o.guard, true), confirmDestructive: flag(o.confirmDestructive, true) }
14}
15
16export const modeOf = (o: ModOptions): Record<string, boolean> => ({ guard: o.guard, confirmDestructive: o.confirmDestructive })
17hooks/status.ts 17 lines1/** Counters the mod keeps for the session and writes to `.claude-flow/iot-mod/status.json` for the console. */
2export type Stats = {
3 blocked: number
4 scans: number
5 commands: number
6 lastDenied?: string
7}
8
9export const newStats = (): Stats => ({ blocked: 0, scans: 0, commands: 0 })
10
11export const STATUS_PATH = '.claude-flow/iot-mod/status.json'
12
13/** The file's text: mode flags plus the counters; `version` lets the console refuse a shape it does not know. */
14export function statusText(stats: Stats, mode: Record<string, boolean>, nowMs: number): string {
15 return `${JSON.stringify({ version: 1, updatedMs: nowMs, ...mode, ...stats }, null, 2)}\n`
16}
17hooks/tools.ts 24 lines1import type { ToolInfo } from 'claude-code'
2
3/** `mcp__<server>__<tool>` into its two halves; tool names never hold a double underscore. */
4export function splitName(name: string): { server: string; tool: string } | undefined {
5 if (!name.startsWith('mcp__')) return undefined
6 const at = name.lastIndexOf('__')
7 return at > 5 ? { server: name.slice(5, at), tool: name.slice(at + 2) } : undefined
8}
9
10/** The bare tool name: `mcp__srv__memory_store` and `memory_store` both give `memory_store`. */
11export const bare = (name: string) => splitName(name)?.tool ?? name
12
13/** A connected MCP tool whose bare name is `suffix`, split into what `$.mcp.call` takes. */
14export function findTool(tools: readonly ToolInfo[], suffix: string): { server: string; tool: string } | undefined {
15 const hit = tools.find(t => t.mcp && bare(t.name) === suffix)
16 return hit && splitName(hit.name)
17}
18
19/** The tool input's `namespace` field when it is a string, else ''. */
20export const namespaceOf = (input: unknown): string => {
21 const ns = (input as { namespace?: unknown } | null)?.namespace
22 return typeof ns === 'string' ? ns : ''
23}
24hooks/screen.ts 261 lines1/**
2 * Pure text screening for the AgentDB mod (ADR-445). Two jobs: find secrets (so none is stored) and find prompt-injection phrasing (so
3 * retrieved memory cannot instruct the model). Findings are NAMES only: the matched text is never returned, logged or counted by value.
4 */
5
6// BEGIN SHARED SCREEN (generated from plugins/ruflo-agentdb/hooks/screen.ts by scripts/sync-mod-screen.mjs; do not edit in a copy)
7export type Rules = readonly (readonly [string, RegExp])[]
8
9/** The secret shapes every mod screens for. A plugin adds its own after these, outside the markers. */
10export const COMMON_SECRETS: Rules = [
11 ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
12 ['aws access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
13 ['github token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,})\b/],
14 ['slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
15 ['slack webhook', /\bhooks\.slack\.com\/services\/T[A-Z0-9]{6,}\/B[A-Z0-9]{6,}\/[A-Za-z0-9]{16,}/],
16 ['google api key', /\bAIza[0-9A-Za-z_-]{35}\b/],
17 ['anthropic or openai key', /\bsk-(?:(?:ant|proj|svcacct|admin)-[A-Za-z0-9_-]{20,}|(?=[A-Za-z]{0,40}\d)[A-Za-z0-9]{32,})/],
18 ['stripe key', /\b[rs]k_live_[A-Za-z0-9]{16,}/],
19 ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
20 ['huggingface token', /\bhf_[A-Za-z0-9]{30,}\b/],
21 ['sendgrid key', /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/],
22 ['twilio key', /\bSK[0-9a-f]{32}\b/],
23 ['jwt', /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/],
24 ['bearer token', /\bBearer\s+([A-Za-z0-9._~+/=-]{24,})/],
25 ['database url with credentials', /\b[a-z][a-z0-9+.-]{1,20}:\/\/[^\s:@/]+:[^\s@/]{3,}@[^\s/]+/i],
26]
27
28export const INJECTION: Rules = [
29 ['override instructions', /\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|system)\b[^.\n]{0,30}\b(?:instructions?|rules?|prompts?|guidelines?)\b/i],
30 ['role reassignment', /\byou are (?:now|no longer)\b|\bact as (?:an? )?(?:unrestricted|jailbroken)\b/i],
31 ['new instructions', /\b(?:new|updated|real) (?:system )?instructions?\s*:/i],
32 ['fake role tags', /<\/?\s*(?:system|assistant|developer|instructions?)\s*>|^\s*(?:system|assistant)\s*:/im],
33 ['concealment', /\bdo not (?:tell|inform|mention|reveal)[^.\n]{0,30}\b(?:user|human|operator)\b/i],
34 ['exfiltration', /\b(?:exfiltrate|send|post|upload)\b[^.\n]{0,50}\b(?:secrets?|credentials?|tokens?|api keys?|\.env)\b/i],
35 ['shell pipe', /\b(?:curl|wget)\b[^|\n]{0,200}\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i],
36]
37
38// C0/C1 controls (keeping tab and newline), DEL, soft hyphen, combining grapheme joiner, Arabic letter mark, Hangul and Mongolian fillers/separators,
39// zero-width, bidi (overrides and isolates) and invisible-format characters, variation selectors; built with escapes, never raw.
40const INVISIBLE = new RegExp(
41 '[\\u0000-\\u0008\\u000b-\\u001f\\u007f-\\u009f\\u00ad\\u034f\\u061c\\u115f\\u1160\\u17b4\\u17b5\\u180b-\\u180e\\u200b-\\u200f\\u2028-\\u202e\\u2060-\\u206f\\u3164\\ufe00-\\ufe0f\\ufeff\\uffa0\\ufff9-\\ufffb]',
42 'g',
43)
44
45/** Longest input scanned in one pass; a longer one keeps its head and tail halves. One regex pass per rule, so cost stays linear. */
46const MAX_SCAN = 200_000
47
48/** Input bounded to MAX_SCAN characters with invisible characters removed, so none can hide a secret or a phrase. */
49export const bare = (text: string) =>
50 (text.length > MAX_SCAN ? text.slice(0, MAX_SCAN / 2) + '\n' + text.slice(-MAX_SCAN / 2) : text).replace(INVISIBLE, '')
51
52// A value is a secret CANDIDATE only when it is not a reference (env var, call, identifier path, placeholder, secret-manager path) and its
53// shape is random enough: at least two character classes, one of them a digit or symbol, and Shannon entropy of at least 2.5 bits per character.
54const PLACEHOLDER = /placeholder|your[-_ ]|example|changeme|change[-_]?me|redacted|dummy|replace[-_]?me|insert[-_]|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
55const REFERENCE =
56 /^(?:\$(?:\{[^}]*\}|\(|[A-Za-z_]\w*$)|%[^%]*%$|<[^>]*>$|\{\{|process\.env|os\.environ|env[.[]|import\.meta|System\.getenv|secrets?\.|vault:|op:\/\/|ref\+|arn:|projects\/[^/]+\/secrets\/|gcp:|kms:|aws:|file:)/i
57const CALL = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\(|^[A-Za-z_$][\w$]*\[/
58const IDENT_PATH = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/
59const UUID = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i
60const NAME_LIKE = /^[a-z][a-z0-9]*(?:[-_./][a-z0-9]+){2,}$/
61
62function entropy(v: string): number {
63 const counts = new Map<string, number>()
64 for (const ch of v) counts.set(ch, (counts.get(ch) ?? 0) + 1)
65 let h = 0
66 for (const n of counts.values()) h -= (n / v.length) * Math.log2(n / v.length)
67 return h
68}
69
70/** True when `v` is a name, call, path or placeholder rather than a literal credential. */
71function isReference(v: string): boolean {
72 if (PLACEHOLDER.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v)) return true
73 return NAME_LIKE.test(v) && v.replace(/\D/g, '').length / v.length < 0.15
74}
75
76export function plausibleSecret(v: string): boolean {
77 if (v.length < 8 || v.length > 256 || /\s/.test(v) || UUID.test(v) || isReference(v)) return false
78 const symbol = /[^A-Za-z0-9]/.test(v)
79 const digit = /\d/.test(v)
80 const classes = [/[a-z]/.test(v), /[A-Z]/.test(v), digit, symbol].filter(Boolean).length
81 return classes >= 2 && (digit || symbol) && entropy(v) >= 2.5
82}
83
84/** Under a secret-named key a literal this long is a secret even with one character class or a UUID shape, unless it is a clear reference. */
85const KEYED_MIN = 20
86const PLACEHOLDER_WORD = /(?:^|[^a-z])(?:your|placeholder|changeme|change[-_]?me|example|redacted|dummy|replace[-_]?me|insert)(?:[^a-z]|$)|\*{3,}|x{5,}|\.{3}|^(?:none|null|undefined|true|false)$/i
87const URL_NO_CREDS = /^[a-z][a-z0-9+.-]{1,20}:\/\/[^\s@]*$/i
88
89/** Three or more lowercase hyphen-separated words (no hex or digit-only run of 8+, few digits), such as my-k8s-secret-name-for-database. */
90function hyphenName(v: string): boolean {
91 const parts = v.split('-')
92 return parts.length >= 3 && parts.every(p => /^[a-z0-9]{2,}$/.test(p) && !/^[0-9a-f]{8,}$/.test(p)) && v.replace(/\D/g, '').length / v.length < 0.15
93}
94
95function keyedSecret(v: string): boolean {
96 if (v.length < KEYED_MIN || v.length > 256 || /\s/.test(v)) return false
97 return !(PLACEHOLDER_WORD.test(v) || REFERENCE.test(v) || CALL.test(v) || IDENT_PATH.test(v) || URL_NO_CREDS.test(v) || (!UUID.test(v) && hyphenName(v)))
98}
99
100const KEY_NAME = /(?:api[_-]?key|secret|token|passw(?:or)?d|passwd|pwd|credential|private[_-]?key|auth(?!or))s?[A-Za-z0-9_-]{0,40}["']?\s*[:=]\s*/gi
101const QUOTED = /(["'\x60])((?:(?!\1)[^\n]){1,256})\1/y
102const BARE_VALUE = /[^\s"'\x60,;]{1,256}/y
103const QUERY_VALUE = /[^\s"'\x60,;&]{1,256}/y
104
105/** A secret-named key assigned a literal value: env style, JSON, YAML, code. Values that are calls, references or placeholders do not count. */
106function assignmentSecret(text: string): boolean {
107 let valueEnd = 0
108 for (const m of text.matchAll(KEY_NAME)) {
109 if (m.index < valueEnd) continue // a key-looking word inside the previous value, such as secretsmanager in an ARN
110 const at = m.index + m[0].length
111 let back = m.index
112 while (back > 0 && m.index - back < 64 && /[A-Za-z0-9_.-]/.test(text.charAt(back - 1))) back--
113 const re = /["'\x60]/.test(text.charAt(at)) ? QUOTED : /[?&]/.test(text.charAt(back - 1)) ? QUERY_VALUE : BARE_VALUE
114 re.lastIndex = at
115 const hit = re.exec(text)
116 const v = hit && (hit[2] ?? hit[0])
117 valueEnd = hit ? at + hit[0].length : at
118 if (v && (plausibleSecret(v) || keyedSecret(v))) return true
119 }
120 return false
121}
122
123/** A password in a URL's userinfo that is not a placeholder such as user:password or ${DB_PASSWORD}. */
124function urlCredential(url: string): boolean {
125 const pass = /^[^:]+:\/\/[^\s:@/]+:([^\s@/]+)@/.exec(url)?.[1]
126 if (!pass || /\$\{|\{\{|%\(|%s/.test(pass)) return false
127 return !/^(?:password|passwd|pass|pwd|secret|changeme|dbpassword|db_password|\$\w*|<.*>|\{.*\}|\*+|x+)$/i.test(pass) && !PLACEHOLDER.test(pass)
128}
129
130const CHECKS: Readonly<Record<string, (m: RegExpMatchArray) => boolean>> = {
131 'bearer token': m => !isReference(m[1] ?? ''),
132 'database url with credentials': m => urlCredential(m[0]),
133 'database url with password': m => urlCredential(m[0]),
134}
135const globals = new WeakMap<RegExp, RegExp>()
136
137function matches(name: string, re: RegExp, text: string): boolean {
138 if (name === 'key assignment') return assignmentSecret(text)
139 const check = CHECKS[name]
140 if (!check) return re.test(text)
141 let g = globals.get(re)
142 if (!g) globals.set(re, (g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g')))
143 for (const m of text.matchAll(g)) if (check(m)) return true
144 return false
145}
146
147/**
148 * The text textsOf appends when it had to drop input (a node, character or per-string budget ran out). It is never matched against a rule:
149 * `names` reports it as a finding of its own, so every guard that asks "is there a secret in these texts" refuses what it could not read in full.
150 */
151export const TRUNCATED = 'ruflo-screen: input exceeded the screening budget'
152export const TRUNCATED_NAME = 'input too large to screen'
153
154/** Names of the rules that match `text` (already bare'd). A rule named 'key assignment' is judged by assignmentSecret, whatever its regex. */
155export const names = (rules: Rules, text: string) => text === TRUNCATED ? [TRUNCATED_NAME] : rules.filter(([name, re]) => matches(name, re, text)).map(([name]) => name)
156
157export type Findings = { readonly secrets: readonly string[]; readonly injection: readonly string[] }
158
159/** Names of every secret shape in `secrets` and every injection phrase found in `text`. Cost is linear in the capped input. */
160export function screenWith(secrets: Rules, text: string): Findings {
161 const bounded = bare(text)
162 return { secrets: names(secrets, bounded), injection: names(INJECTION, bounded) }
163}
164
165export const hasSecretIn = (secrets: Rules, text: string) => names(secrets, bare(text)).length > 0
166
167/** Makes stored text safe to show: no control or bidi characters, whitespace collapsed, at most `max` characters. */
168export function tidy(text: string, max: number): string {
169 const flat = text.replace(INVISIBLE, '').replace(/\s+/g, ' ').trim()
170 return flat.length > max ? `${flat.slice(0, Math.max(0, max - 1))}…` : flat
171}
172/** Bounds for textsOf: nodes visited, characters returned, the longest string read in full, the size of one returned chunk, chunk overlap. */
173export type TextLimits = { readonly nodes?: number; readonly chars?: number; readonly perString?: number }
174const NODES = 20_000
175const CHARS = 2_000_000
176const PER_STRING = 1_500_000
177const OVERLAP = 2_048
178const BARE_KEY_MIN = 8
179
180/** A string as texts the screen can read whole: one text up to MAX_SCAN, else overlapping MAX_SCAN windows so a secret anywhere is inside one. */
181function windows(text: string, out: string[]): void {
182 if (text.length <= MAX_SCAN) {
183 out.push(text)
184 return
185 }
186 for (let at = 0; ; at += MAX_SCAN - OVERLAP) {
187 out.push(text.slice(at, at + MAX_SCAN))
188 if (at + MAX_SCAN >= text.length) return
189 }
190}
191
192/**
193 * Every string in a tool input, for the screen to read: iterative (no recursion, so nesting 5000 deep cannot overflow the stack) and
194 * breadth-first (siblings before depth, so a long list cannot hide a nested value). A string under an object key comes back as `key=value`,
195 * so a secret-named key is judged with its value; a key whose value is not a string is returned bare. Strings longer than the screen window
196 * come back as overlapping windows; one over `perString` keeps its head and tail. Work is bounded by `nodes` slots and `chars` characters.
197 * Anything dropped (slots or characters ran out, or a string lost its middle) is reported by a final TRUNCATED text, which `names` and
198 * `hasSecretIn` count as a finding, so the screen fails closed instead of passing what it did not read.
199 */
200export function textsOf(input: unknown, limits: TextLimits = {}): string[] {
201 const out: string[] = []
202 let slots = limits.nodes ?? NODES
203 let chars = limits.chars ?? CHARS
204 const perString = limits.perString ?? PER_STRING
205 let truncated = false
206 const take = (text: string): void => {
207 if (chars <= 0) {
208 truncated = true
209 return
210 }
211 if (text.length <= Math.min(perString, chars)) {
212 chars -= text.length
213 windows(text, out)
214 return
215 }
216 truncated = true
217 const half = Math.floor(Math.min(perString, chars) / 2)
218 chars -= 2 * half
219 windows(text.slice(0, half), out)
220 windows(text.slice(-half), out)
221 }
222 const queue: unknown[] = [input]
223 let head = 0
224 for (; head < queue.length && chars > 0; head++) {
225 const node = queue[head]
226 if (typeof node === 'string') take(node)
227 else if (Array.isArray(node)) {
228 let i = 0
229 for (; i < node.length && slots > 0; i++, slots--) if (i in node) queue.push(node[i])
230 if (i < node.length) truncated = true
231 } else if (typeof node === 'object' && node !== null) {
232 for (const k in node) {
233 if (slots-- <= 0) {
234 truncated = true
235 break
236 }
237 if (!Object.prototype.hasOwnProperty.call(node, k)) continue
238 const v = (node as Record<string, unknown>)[k]
239 if (typeof v === 'string') queue.push(k + '=' + v)
240 else {
241 if (k.length >= BARE_KEY_MIN) take(k)
242 queue.push(v)
243 }
244 }
245 }
246 }
247 if (queue.length > head) truncated = true
248 if (truncated) out.push(TRUNCATED)
249 return out
250}
251// END SHARED SCREEN
252
253const SECRETS: Rules = [
254 ...COMMON_SECRETS,
255 ['key assignment', /\b(?:api[_-]?key|secret|token|passw(?:or)?d|credential)s?["']?\s*[:=]\s*["']?[A-Za-z0-9/+=_.-]{16,}/i],
256]
257
258export const scan = (text: string): Findings => screenWith(SECRETS, text)
259
260export const hasSecret = (text: string) => hasSecretIn(SECRETS, text)
261