deepdebug-local-agent 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/npm-publish.yml +16 -9
- package/CHANGELOG.md +7 -0
- package/docs/dlp.md +78 -0
- package/docs/releasing.md +37 -43
- package/env.example +4 -0
- package/package.json +1 -1
- package/scripts/next-version.mjs +75 -0
- package/src/dlp/engine.js +111 -0
- package/src/dlp/index.js +137 -0
- package/src/dlp/rules.js +97 -0
- package/src/dlp/validators.js +123 -0
- package/src/siblings/incident-forwarder.js +3 -1
- package/test/dlp/engine.test.js +173 -0
- package/test/dlp/fixtures.js +126 -0
- package/test/dlp/index.test.js +166 -0
- package/test/next-version.test.js +48 -0
|
@@ -64,7 +64,7 @@ jobs:
|
|
|
64
64
|
git add package-lock.json || true
|
|
65
65
|
git diff --staged --quiet || git commit -m "chore: update package-lock.json [skip ci]"
|
|
66
66
|
|
|
67
|
-
- name: Bump version (
|
|
67
|
+
- name: Bump version (semantic, from the commits; or the exact dispatch input)
|
|
68
68
|
env:
|
|
69
69
|
INPUT_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }}
|
|
70
70
|
run: |
|
|
@@ -77,15 +77,22 @@ jobs:
|
|
|
77
77
|
LATEST_TAG="${LATEST_TAG#v}"
|
|
78
78
|
PKG="$(node -p "require('./package.json').version")"
|
|
79
79
|
BASE="$(printf '%s\n%s\n' "${LATEST_TAG:-0.0.0}" "$PKG" | sort -V | tail -1)"
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
80
|
+
# Semantic version from the commits since the last release tag (Conventional
|
|
81
|
+
# Commits, see scripts/next-version.mjs and docs/releasing.md):
|
|
82
|
+
# BREAKING CHANGE / "type!:" -> major (X.0.0), "feat:" -> minor (0.X.0),
|
|
83
|
+
# anything else (fix, chore, docs, ci, ...) -> patch (0.0.X).
|
|
84
|
+
# Computed up front and applied in ONE `npm version` call. A separate
|
|
85
|
+
# `npm version "$BASE" --no-git-tag-version` rewrites package.json/
|
|
86
|
+
# package-lock.json whenever the tag is ahead of package.json, leaving the
|
|
87
|
+
# tree dirty so the following `npm version` aborts with "Git working
|
|
88
|
+
# directory not clean".
|
|
89
|
+
RANGE="HEAD"
|
|
90
|
+
if [ -n "${LATEST_TAG}" ] && git rev-parse -q --verify "refs/tags/v${LATEST_TAG}" >/dev/null; then
|
|
91
|
+
RANGE="v${LATEST_TAG}..HEAD"
|
|
92
|
+
fi
|
|
93
|
+
NEXT="$(git log -z --no-merges --format=%B "$RANGE" | node scripts/next-version.mjs "$BASE")"
|
|
86
94
|
# Release override (workflow_dispatch input `version`, e.g. 1.1.0): used
|
|
87
|
-
# as-is instead of the
|
|
88
|
-
# into 1.1.1 by the automatic bump. Read from env, never interpolated.
|
|
95
|
+
# as-is instead of the computed bump (for example to force a version). Read from env, never interpolated.
|
|
89
96
|
if [ -n "${INPUT_VERSION}" ]; then
|
|
90
97
|
if ! printf '%s' "$INPUT_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
|
91
98
|
echo "::error::version input '$INPUT_VERSION' is not X.Y.Z"; exit 1
|
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,13 @@ Security release (security review 2026-10-11, C1 and H4). The agent now fails
|
|
|
6
6
|
closed by default. Read "Upgrade" before rolling it out to agents whose
|
|
7
7
|
platform side does not sign commands yet.
|
|
8
8
|
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **DLP module (`src/dlp/`, flag `LOCAL_AGENT_DLP_MODE=off|warn|enforce`, default `off`).**
|
|
12
|
+
Masks secrets and PII in content leaving the agent. First wired point: the
|
|
13
|
+
`message` of incidents forwarded by the adapter forwarder. See `docs/dlp.md`.
|
|
14
|
+
With the flag off, output is unchanged.
|
|
15
|
+
|
|
9
16
|
### Changed defaults
|
|
10
17
|
|
|
11
18
|
- **Signed commands: `enforce` by default.** `LOCAL_AGENT_SIGNATURE_MODE`
|
package/docs/dlp.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# DLP: masking secrets and PII before content leaves the agent (T4-09)
|
|
2
|
+
|
|
3
|
+
Status: module + first integration point. Flag `LOCAL_AGENT_DLP_MODE`, default **off**.
|
|
4
|
+
|
|
5
|
+
## Flags
|
|
6
|
+
|
|
7
|
+
| Variable | Values | Default | Effect |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| `LOCAL_AGENT_DLP_MODE` | `off`, `warn`, `enforce` | `off` | `off`: nothing scanned, output identical to before. `warn`: output unchanged, counters and local log report what enforce would redact. `enforce`: hits replaced by `[REDACTED:<rule_id>]`. Any other value means `off`. |
|
|
10
|
+
| `LOCAL_AGENT_DLP_BLOCK_CLASSES` | comma list of classes | empty | Enforce only. If a hit belongs to a listed class the whole text is replaced by `[BLOCKED:<class>]`. |
|
|
11
|
+
| `LOCAL_AGENT_DLP_MAX_BYTES` | integer | `2000000` | Larger inputs are not scanned; enforce fails closed (`[REDACTED:dlp_oversize]`). |
|
|
12
|
+
|
|
13
|
+
Classes: `private-key`, `cloud-credential`, `vcs-token`, `payment-key`, `saas-token`, `token`,
|
|
14
|
+
`credential`, `connection-string`, `url-credential`, `pii-email`, `pii-phone`, `pii-id`,
|
|
15
|
+
`pii-financial`, `high-entropy`.
|
|
16
|
+
|
|
17
|
+
## API (reusable by the WebSocket work, task M)
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { maskSensitive, maskDeep, protectEgress, protectEgressAsync, protectDeep,
|
|
21
|
+
getDlpStats, rulesetHash, RULESET_VERSION } from './src/dlp/index.js';
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `maskSensitive(text, { mode, blockClasses, maxBytes })`: **pure** function (no env, no state).
|
|
25
|
+
Returns `{ text, redactions: {rule_id: n}, total, blocked, blockedClasses, oversize, rulesetVersion }`.
|
|
26
|
+
- `maskDeep(value, opts)`: same over every string of a JSON-like value (keys untouched, input not mutated).
|
|
27
|
+
- `protectEgress(text, { source, log, env })`: reads the flag from the environment, updates counters,
|
|
28
|
+
writes a local record with counts per rule (never the value). Returns `{ text, ... }`.
|
|
29
|
+
- `protectEgressAsync(text, { policyDecision, timeoutMs, ... })`: as above, plus a per-action
|
|
30
|
+
PolicyGate decision (D19). In `enforce`, anything other than an explicit `'ALLOW'` (DENY, unknown
|
|
31
|
+
answer, throw, timeout, PolicyGate offline) returns `[DLP_DENIED]`.
|
|
32
|
+
- `protectDeep(value, opts)`: env-driven `maskDeep`.
|
|
33
|
+
- `getDlpStats()`: `{ mode, ruleset_version, ruleset_hash, scanned, blocked, denied, dlp_redactions_total: {rule_id: n} }`.
|
|
34
|
+
Counts only; intended for the heartbeat (`dlp_redactions_total` by rule). `rulesetHash()` is what
|
|
35
|
+
`config.update` can carry to prove which ruleset is active.
|
|
36
|
+
|
|
37
|
+
Fail-closed rules in `enforce`: internal error, oversize input, policy not ALLOW = content withheld.
|
|
38
|
+
In `warn`/`off` the original text is always returned.
|
|
39
|
+
|
|
40
|
+
## Where it is applied today
|
|
41
|
+
|
|
42
|
+
- `src/siblings/incident-forwarder.js`: `message` of the incident POSTed to the gateway (log excerpt).
|
|
43
|
+
|
|
44
|
+
Not applied yet (follow-ups, outside this PR's boundary): `content.request` responses and `job.event`
|
|
45
|
+
(WebSocket channel, task M), prompts of the loop, error messages returned by routes. They can call
|
|
46
|
+
`protectEgress`/`protectDeep`/`maskSensitive` directly.
|
|
47
|
+
|
|
48
|
+
## Ruleset (version `2026.10.28-1`, 40 rules)
|
|
49
|
+
|
|
50
|
+
Order matters (specific before generic). Highlights: PEM/OpenSSH/PGP private keys; AWS key id and secret;
|
|
51
|
+
GCP API key, OAuth token and client secret; Azure storage key; DigitalOcean; GitHub (classic, fine-grained);
|
|
52
|
+
GitLab; npm; PyPI; Stripe (keys, webhook secret); Shopify; Slack (token, webhook); Discord webhook;
|
|
53
|
+
SendGrid; Twilio; Mailgun; Hugging Face; Telegram; Anthropic; OpenAI; JWT; Bearer and Basic headers;
|
|
54
|
+
DB connection strings (postgres, mysql, mongodb, redis, amqp, mssql, cockroach); JDBC and ADO passwords;
|
|
55
|
+
credentials in URLs; generic `name = literal` assignments (with a validator that ignores identifiers,
|
|
56
|
+
calls, templates and placeholders); e-mail (ignores asset names and documentation domains);
|
|
57
|
+
IBAN (mod-97); card numbers (Luhn); PT NIF (checksum and keyword context); international phone
|
|
58
|
+
(leading `+`/`00`, 9 to 15 digits); opaque high-entropy blobs (40+ chars, ignores hex digests, UUIDs,
|
|
59
|
+
SRI hashes and paths). Patterns are reimplemented from public token formats; no third-party rule
|
|
60
|
+
files were copied. The PiiEgressMasker of the agents repo was used as a behavioural reference only.
|
|
61
|
+
|
|
62
|
+
Bump `RULESET_VERSION` in `src/dlp/rules.js` on any change.
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
`test/dlp/`: every rule has at least one synthetic positive (fixtures are built at runtime from split
|
|
67
|
+
prefixes and a deterministic hash stream, no real secrets); 40 negative lines (code, hashes, UUIDs,
|
|
68
|
+
SSH remotes, templates) must be unchanged; mode, block, oversize, idempotence, performance on
|
|
69
|
+
pathological 1 MB inputs, policy fail-closed and the forwarder integration (flag off = byte-identical).
|
|
70
|
+
|
|
71
|
+
Indicative false-positive check (not a measured product metric): running `enforce` over this repo
|
|
72
|
+
(182 text files, ~41k lines) altered about 40 lines, almost all true positives (e-mail addresses in
|
|
73
|
+
docs and test fixtures, literal tokens in tests). The plan's target (<= 2 % FP on a real code
|
|
74
|
+
corpus) still needs to be measured on a customer-like corpus during the pilot.
|
|
75
|
+
|
|
76
|
+
## Rollback
|
|
77
|
+
|
|
78
|
+
Unset `LOCAL_AGENT_DLP_MODE` (or set `off`) and restart the agent. No data, schema or protocol changes.
|
package/docs/releasing.md
CHANGED
|
@@ -1,43 +1,37 @@
|
|
|
1
|
-
# Releasing
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
`
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
workflows, so after the npm release run Actions → **Build BYOC Agent Image**
|
|
39
|
-
on `main` with `version` = `1.1.0`
|
|
40
|
-
(see `docs/byoc-agent-image.md`). Tick `supply_chain` to also sign the image
|
|
41
|
-
(cosign keyless), attach the SBOM and the provenance, and verify them.
|
|
42
|
-
For `v*` tag and release runs the same job runs when the repository variable
|
|
43
|
-
`BYOC_SUPPLY_CHAIN` is `true`. Default: off.
|
|
1
|
+
# Releasing (npm)
|
|
2
|
+
|
|
3
|
+
Every push to `main` publishes `deepdebug-local-agent` to npm through
|
|
4
|
+
`.github/workflows/npm-publish.yml` (Trusted Publishing, OIDC; no token). The version
|
|
5
|
+
is chosen automatically from the commit messages since the last release tag, following
|
|
6
|
+
[Semantic Versioning](https://semver.org) and [Conventional Commits](https://www.conventionalcommits.org):
|
|
7
|
+
|
|
8
|
+
| Level | Version step | Commit that triggers it |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| Patch | `0.0.X` bug fix | `fix:`, and any other type (`perf:`, `refactor:`, `docs:`, `chore:`, `ci:`, ...) or a message without a type |
|
|
11
|
+
| Minor | `0.X.0` new functionality | `feat:` or `feat(scope):` |
|
|
12
|
+
| Major | `X.0.0` big, breaking change | `type!:` (for example `feat!:`) or a `BREAKING CHANGE:` footer |
|
|
13
|
+
|
|
14
|
+
The highest level among the commits since the last `vX.Y.Z` tag wins. Merge commits and the
|
|
15
|
+
CI bump commits (`[skip ci]`) are ignored. The rules live in `scripts/next-version.mjs` and are
|
|
16
|
+
unit tested in `test/next-version.test.js`.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
fix: reject unsigned requests on /workspace/exec -> 1.1.0 becomes 1.1.1
|
|
22
|
+
feat(ws): metadata-only channel -> 1.1.1 becomes 1.2.0
|
|
23
|
+
feat!: require node 22 -> 1.2.0 becomes 2.0.0
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Tips
|
|
27
|
+
|
|
28
|
+
- Write the PR title as a Conventional Commit and use "Squash and merge", so the version
|
|
29
|
+
follows the title. With a normal merge, the commits inside the PR decide.
|
|
30
|
+
- To skip a release (for example a docs-only change), put `[skip ci]` in the merge commit
|
|
31
|
+
subject.
|
|
32
|
+
- To force a version, run the workflow by hand with the `version` input:
|
|
33
|
+
`gh workflow run npm-publish.yml -f version=X.Y.Z` (must be greater than the highest tag).
|
|
34
|
+
- The workflow tags and commits the bump only after a successful publish, so a failed
|
|
35
|
+
publish does not burn a version. npm reviews each new version automatically: it can stay
|
|
36
|
+
in "Validating" for a few minutes before it becomes installable.
|
|
37
|
+
- Provenance is not published (`--provenance` is rejected by npm for private repositories).
|
package/env.example
CHANGED
|
@@ -69,3 +69,7 @@ LOG_LEVEL=info
|
|
|
69
69
|
# LOCAL_AGENT_TENANT_ID=
|
|
70
70
|
# LOCAL_AGENT_WORKSPACE_ID=
|
|
71
71
|
# LOCAL_AGENT_REQUIRE_WORKSPACE=false
|
|
72
|
+
|
|
73
|
+
# DLP (T4-09): mask secrets/PII before content leaves the agent. Default off.
|
|
74
|
+
# LOCAL_AGENT_DLP_MODE=off|warn|enforce (warn = count only, enforce = redact)
|
|
75
|
+
# LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (enforce: withhold whole text)
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* next-version.mjs: semantic version for the automatic npm release.
|
|
4
|
+
*
|
|
5
|
+
* major (X.0.0) a breaking change: "type!:" subject or a "BREAKING CHANGE:" footer
|
|
6
|
+
* minor (0.X.0) new functionality: a "feat:" commit
|
|
7
|
+
* patch (0.0.X) everything else (fix, perf, refactor, docs, chore, ci, ...)
|
|
8
|
+
*
|
|
9
|
+
* Conventional Commits. The highest level among the commits since the last release
|
|
10
|
+
* tag wins. Merge commits and the CI auto-bump commits ("[skip ci]") are ignored.
|
|
11
|
+
*
|
|
12
|
+
* CLI: git log -z --no-merges --format=%B <range> | node scripts/next-version.mjs <base-version>
|
|
13
|
+
* prints the next version; stderr says which commit decided the bump.
|
|
14
|
+
*/
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const SUBJECT_TYPE = /^([a-z]+)(\([^)]*\))?(!)?:\s/i
|
|
18
|
+
const BREAKING_FOOTER = /^BREAKING[ -]CHANGE:\s/m
|
|
19
|
+
|
|
20
|
+
/** @param {string} message full commit message (subject + body) */
|
|
21
|
+
export function classify(message) {
|
|
22
|
+
const text = String(message || '').trim()
|
|
23
|
+
if (!text) return null
|
|
24
|
+
const subject = text.split('\n', 1)[0]
|
|
25
|
+
if (/\[skip ci\]/i.test(subject) || /^Merge /.test(subject)) return null
|
|
26
|
+
const m = SUBJECT_TYPE.exec(subject)
|
|
27
|
+
if (BREAKING_FOOTER.test(text) || (m && m[3])) return 'major'
|
|
28
|
+
if (m && m[1].toLowerCase() === 'feat') return 'minor'
|
|
29
|
+
return 'patch'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RANK = { patch: 0, minor: 1, major: 2 }
|
|
33
|
+
|
|
34
|
+
/** @param {string[]} messages commit messages since the last release */
|
|
35
|
+
export function bumpFor(messages) {
|
|
36
|
+
let level = 'patch'
|
|
37
|
+
for (const msg of messages) {
|
|
38
|
+
const c = classify(msg)
|
|
39
|
+
if (c && RANK[c] > RANK[level]) level = c
|
|
40
|
+
}
|
|
41
|
+
return level
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} base X.Y.Z @param {'major'|'minor'|'patch'} bump */
|
|
45
|
+
export function nextVersion(base, bump) {
|
|
46
|
+
const parts = String(base).split('.').map(Number)
|
|
47
|
+
if (parts.length !== 3 || parts.some((n) => !Number.isInteger(n) || n < 0)) {
|
|
48
|
+
throw new Error(`base version '${base}' is not X.Y.Z`)
|
|
49
|
+
}
|
|
50
|
+
const [a, b, c] = parts
|
|
51
|
+
if (bump === 'major') return `${a + 1}.0.0`
|
|
52
|
+
if (bump === 'minor') return `${a}.${b + 1}.0`
|
|
53
|
+
return `${a}.${b}.${c + 1}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const base = process.argv[2]
|
|
58
|
+
if (!base) {
|
|
59
|
+
console.error('usage: next-version.mjs <base-version> < commit messages separated by NUL')
|
|
60
|
+
process.exit(2)
|
|
61
|
+
}
|
|
62
|
+
const chunks = []
|
|
63
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
64
|
+
const messages = Buffer.concat(chunks).toString('utf8').split('\0')
|
|
65
|
+
const bump = bumpFor(messages)
|
|
66
|
+
console.error(`[next-version] ${messages.filter((m) => classify(m)).length} releasable commit(s) since the last release, bump=${bump}`)
|
|
67
|
+
console.log(nextVersion(base, bump))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
71
|
+
main().catch((e) => {
|
|
72
|
+
console.error(e.message)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP engine: pure functions, no env, no I/O, no module state.
|
|
3
|
+
*
|
|
4
|
+
* maskSensitive(text, opts) -> { text, redactions, total, blocked, blockedClasses, ... }
|
|
5
|
+
* mode 'off' text returned untouched, nothing scanned.
|
|
6
|
+
* mode 'warn' text returned untouched; redactions/blocked report what enforce WOULD do.
|
|
7
|
+
* mode 'enforce' text returned with every hit replaced by [REDACTED:<rule_id>];
|
|
8
|
+
* when a hit belongs to a class in blockClasses the whole text is
|
|
9
|
+
* replaced by [BLOCKED:<class>] and `blocked` is true.
|
|
10
|
+
* The matched value is never returned, logged or counted: only rule ids and counts.
|
|
11
|
+
*/
|
|
12
|
+
import { RULES, RULESET_VERSION } from './rules.js';
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
|
|
15
|
+
export const MODES = Object.freeze(['off', 'warn', 'enforce']);
|
|
16
|
+
export const DEFAULT_MAX_BYTES = 2_000_000;
|
|
17
|
+
|
|
18
|
+
/** @param {unknown} v @returns {'off'|'warn'|'enforce'} unknown values fall back to 'off'. */
|
|
19
|
+
export function normalizeMode(v) {
|
|
20
|
+
const m = typeof v === 'string' ? v.trim().toLowerCase() : '';
|
|
21
|
+
return MODES.includes(m) ? m : 'off';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stable hash of the active ruleset (sent with config.update / heartbeat). */
|
|
25
|
+
export function rulesetHash(rules = RULES) {
|
|
26
|
+
const h = createHash('sha256');
|
|
27
|
+
h.update(RULESET_VERSION);
|
|
28
|
+
for (const rule of rules) h.update(`\n${rule.id}|${rule.class}|${rule.re.source}|${rule.re.flags}|${rule.group ?? 0}`);
|
|
29
|
+
return h.digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const REDACTED = (id) => `[REDACTED:${id}]`;
|
|
33
|
+
export const BLOCKED = (cls) => `[BLOCKED:${cls}]`;
|
|
34
|
+
export const OVERSIZE = '[REDACTED:dlp_oversize]';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} text
|
|
38
|
+
* @param {{mode?: string, rules?: ReadonlyArray<object>, blockClasses?: Iterable<string>, maxBytes?: number}} [opts]
|
|
39
|
+
*/
|
|
40
|
+
export function maskSensitive(text, opts = {}) {
|
|
41
|
+
const mode = normalizeMode(opts.mode);
|
|
42
|
+
const empty = { text, mode, redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false, rulesetVersion: RULESET_VERSION };
|
|
43
|
+
if (mode === 'off' || typeof text !== 'string' || text.length === 0) return empty;
|
|
44
|
+
|
|
45
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
46
|
+
if (text.length > maxBytes) {
|
|
47
|
+
// Cannot scan safely in bounded time: enforce fails closed, warn only reports.
|
|
48
|
+
return { ...empty, text: mode === 'enforce' ? OVERSIZE : text, oversize: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rules = opts.rules ?? RULES;
|
|
52
|
+
const blockSet = new Set(opts.blockClasses ?? []);
|
|
53
|
+
const redactions = {};
|
|
54
|
+
const hitClasses = new Set();
|
|
55
|
+
let out = text;
|
|
56
|
+
let total = 0;
|
|
57
|
+
|
|
58
|
+
for (const rule of rules) {
|
|
59
|
+
const re = new RegExp(rule.re.source, rule.re.flags.includes('g') ? rule.re.flags : rule.re.flags + 'g');
|
|
60
|
+
out = out.replace(re, (...args) => {
|
|
61
|
+
const match = args[0];
|
|
62
|
+
const groupIdx = rule.group ?? 0;
|
|
63
|
+
const target = groupIdx === 0 ? match : args[groupIdx];
|
|
64
|
+
if (typeof target !== 'string' || target.length === 0) return match;
|
|
65
|
+
if (target.includes('[REDACTED:')) return match; // never re-mask a placeholder (idempotence)
|
|
66
|
+
if (rule.validate && !rule.validate(target)) return match;
|
|
67
|
+
redactions[rule.id] = (redactions[rule.id] || 0) + 1;
|
|
68
|
+
hitClasses.add(rule.class);
|
|
69
|
+
total++;
|
|
70
|
+
if (groupIdx === 0) return REDACTED(rule.id);
|
|
71
|
+
const at = match.lastIndexOf(target);
|
|
72
|
+
return match.slice(0, at) + REDACTED(rule.id) + match.slice(at + target.length);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const blockedClasses = [...hitClasses].filter((c) => blockSet.has(c));
|
|
77
|
+
const blocked = blockedClasses.length > 0;
|
|
78
|
+
if (mode === 'warn') return { ...empty, redactions, total, blocked, blockedClasses };
|
|
79
|
+
return { ...empty, text: blocked ? BLOCKED(blockedClasses[0]) : out, redactions, total, blocked, blockedClasses };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Apply maskSensitive to every string in a JSON-like value (objects, arrays).
|
|
84
|
+
* Keys are not touched. Returns a new value plus the merged report.
|
|
85
|
+
*/
|
|
86
|
+
export function maskDeep(value, opts = {}) {
|
|
87
|
+
const report = { redactions: {}, total: 0, blocked: false, blockedClasses: [], oversize: false };
|
|
88
|
+
const seen = new WeakSet();
|
|
89
|
+
const walk = (v) => {
|
|
90
|
+
if (typeof v === 'string') {
|
|
91
|
+
const res = maskSensitive(v, opts);
|
|
92
|
+
for (const [k, n] of Object.entries(res.redactions)) report.redactions[k] = (report.redactions[k] || 0) + n;
|
|
93
|
+
report.total += res.total;
|
|
94
|
+
report.blocked ||= res.blocked;
|
|
95
|
+
report.oversize ||= res.oversize;
|
|
96
|
+
for (const c of res.blockedClasses) if (!report.blockedClasses.includes(c)) report.blockedClasses.push(c);
|
|
97
|
+
return res.text;
|
|
98
|
+
}
|
|
99
|
+
if (v === null || typeof v !== 'object') return v;
|
|
100
|
+
if (seen.has(v)) return '[circular]';
|
|
101
|
+
seen.add(v);
|
|
102
|
+
if (Array.isArray(v)) return v.map(walk);
|
|
103
|
+
const o = {};
|
|
104
|
+
for (const [k, x] of Object.entries(v)) o[k] = walk(x);
|
|
105
|
+
return o;
|
|
106
|
+
};
|
|
107
|
+
const mode = normalizeMode(opts.mode);
|
|
108
|
+
if (mode === 'off') return { value, mode, ...report };
|
|
109
|
+
const masked = walk(value);
|
|
110
|
+
return { value: mode === 'warn' ? value : masked, mode, ...report };
|
|
111
|
+
}
|
package/src/dlp/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLP for the Local Agent (T4-09): public API.
|
|
3
|
+
*
|
|
4
|
+
* Pure, reusable core (no env, no state): maskSensitive, maskDeep (engine.js)
|
|
5
|
+
* Configured wrappers (env + counters): protectEgress, protectEgressAsync, protectDeep
|
|
6
|
+
*
|
|
7
|
+
* Flag: LOCAL_AGENT_DLP_MODE=off|warn|enforce (default off: output byte-identical to before)
|
|
8
|
+
* LOCAL_AGENT_DLP_BLOCK_CLASSES=private-key,cloud-credential (default none; enforce only)
|
|
9
|
+
* LOCAL_AGENT_DLP_MAX_BYTES=2000000 (enforce fails closed above it)
|
|
10
|
+
*
|
|
11
|
+
* Fail-closed (D19): in enforce, an internal DLP error, an oversize input or a
|
|
12
|
+
* PolicyGate decision that is not an explicit ALLOW (including offline/timeout)
|
|
13
|
+
* withholds the content ([DLP_DENIED]) instead of letting it out.
|
|
14
|
+
*
|
|
15
|
+
* Reports carry rule ids and counts only; never the matched value.
|
|
16
|
+
*/
|
|
17
|
+
import { maskSensitive, maskDeep, normalizeMode, rulesetHash, DEFAULT_MAX_BYTES } from './engine.js';
|
|
18
|
+
import { RULES, RULESET_VERSION, RULE_CLASSES } from './rules.js';
|
|
19
|
+
|
|
20
|
+
export { maskSensitive, maskDeep, normalizeMode, rulesetHash, RULES, RULESET_VERSION, RULE_CLASSES };
|
|
21
|
+
|
|
22
|
+
export const DENIED = '[DLP_DENIED]';
|
|
23
|
+
const NOOP = () => {};
|
|
24
|
+
|
|
25
|
+
/** Read the DLP configuration from an env-like object. */
|
|
26
|
+
export function dlpConfigFromEnv(env = process.env) {
|
|
27
|
+
const maxRaw = Number.parseInt(env.LOCAL_AGENT_DLP_MAX_BYTES ?? '', 10);
|
|
28
|
+
const blockClasses = String(env.LOCAL_AGENT_DLP_BLOCK_CLASSES ?? '')
|
|
29
|
+
.split(',').map((s) => s.trim()).filter((c) => RULE_CLASSES.includes(c));
|
|
30
|
+
return {
|
|
31
|
+
mode: normalizeMode(env.LOCAL_AGENT_DLP_MODE),
|
|
32
|
+
blockClasses,
|
|
33
|
+
maxBytes: Number.isFinite(maxRaw) && maxRaw > 0 ? maxRaw : DEFAULT_MAX_BYTES,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---- counters (for heartbeat / dlp_redactions_total) -------------------------
|
|
38
|
+
const counters = { scanned: 0, redactedTotal: 0, blocked: 0, denied: 0, byRule: new Map() };
|
|
39
|
+
|
|
40
|
+
function record(res) {
|
|
41
|
+
counters.scanned++;
|
|
42
|
+
counters.redactedTotal += res.total;
|
|
43
|
+
if (res.blocked) counters.blocked++;
|
|
44
|
+
for (const [id, n] of Object.entries(res.redactions)) counters.byRule.set(id, (counters.byRule.get(id) || 0) + n);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Snapshot for the heartbeat: counts only. `dlp_redactions_total` is per rule id. */
|
|
48
|
+
export function getDlpStats(env = process.env) {
|
|
49
|
+
const cfg = dlpConfigFromEnv(env);
|
|
50
|
+
return {
|
|
51
|
+
mode: cfg.mode,
|
|
52
|
+
ruleset_version: RULESET_VERSION,
|
|
53
|
+
ruleset_hash: rulesetHash(),
|
|
54
|
+
scanned: counters.scanned,
|
|
55
|
+
blocked: counters.blocked,
|
|
56
|
+
denied: counters.denied,
|
|
57
|
+
dlp_redactions_total: Object.fromEntries(counters.byRule),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resetDlpStats() {
|
|
62
|
+
counters.scanned = 0; counters.redactedTotal = 0; counters.blocked = 0; counters.denied = 0;
|
|
63
|
+
counters.byRule.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function localLog(log, source, res) {
|
|
67
|
+
if (res.total === 0 && !res.oversize) return;
|
|
68
|
+
try {
|
|
69
|
+
// Counts per rule id only. The masked value is never part of the record.
|
|
70
|
+
log({ dlp: true, source: source || 'unknown', mode: res.mode, total: res.total, rules: res.redactions, blocked: res.blocked, oversize: res.oversize }, '[dlp] sensitive content detected');
|
|
71
|
+
} catch { /* logging must never break the data path */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mask one string leaving the agent.
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @param {{env?: object, source?: string, log?: Function}} [opts]
|
|
78
|
+
* @returns {{text: string, mode: string, redactions: Record<string, number>, total: number, blocked: boolean, denied: boolean}}
|
|
79
|
+
*/
|
|
80
|
+
export function protectEgress(text, opts = {}) {
|
|
81
|
+
const cfg = dlpConfigFromEnv(opts.env ?? process.env);
|
|
82
|
+
if (cfg.mode === 'off') return { text, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
|
|
83
|
+
try {
|
|
84
|
+
const res = maskSensitive(text, cfg);
|
|
85
|
+
record(res);
|
|
86
|
+
localLog(opts.log ?? NOOP, opts.source, res);
|
|
87
|
+
return { text: res.text, mode: cfg.mode, redactions: res.redactions, total: res.total, blocked: res.blocked, denied: false };
|
|
88
|
+
} catch {
|
|
89
|
+
if (cfg.mode !== 'enforce') return { text, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
|
|
90
|
+
counters.denied++;
|
|
91
|
+
return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Same as protectEgress, plus a per-action PolicyGate decision (D19).
|
|
97
|
+
* `policyDecision` is optional; when given, enforce requires an explicit 'ALLOW'.
|
|
98
|
+
* Offline, throwing, timing out or answering anything else = DENY.
|
|
99
|
+
* @param {string} text
|
|
100
|
+
* @param {{policyDecision?: () => Promise<string>|string, timeoutMs?: number, env?: object, source?: string, log?: Function}} [opts]
|
|
101
|
+
*/
|
|
102
|
+
export async function protectEgressAsync(text, opts = {}) {
|
|
103
|
+
const cfg = dlpConfigFromEnv(opts.env ?? process.env);
|
|
104
|
+
if (cfg.mode === 'enforce' && typeof opts.policyDecision === 'function') {
|
|
105
|
+
let verdict;
|
|
106
|
+
let timer;
|
|
107
|
+
try {
|
|
108
|
+
const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('policy timeout')), opts.timeoutMs ?? 3000); });
|
|
109
|
+
verdict = await Promise.race([Promise.resolve().then(opts.policyDecision), timeout]);
|
|
110
|
+
} catch {
|
|
111
|
+
verdict = undefined;
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
}
|
|
115
|
+
if (verdict !== 'ALLOW') {
|
|
116
|
+
counters.denied++;
|
|
117
|
+
return { text: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return protectEgress(text, opts);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Mask every string in a JSON-like payload. Returns { value, ... } with the same report fields. */
|
|
124
|
+
export function protectDeep(value, opts = {}) {
|
|
125
|
+
const cfg = dlpConfigFromEnv(opts.env ?? process.env);
|
|
126
|
+
if (cfg.mode === 'off') return { value, mode: 'off', redactions: {}, total: 0, blocked: false, denied: false };
|
|
127
|
+
try {
|
|
128
|
+
const res = maskDeep(value, cfg);
|
|
129
|
+
record(res);
|
|
130
|
+
localLog(opts.log ?? NOOP, opts.source, { ...res, mode: cfg.mode });
|
|
131
|
+
return { ...res, denied: false };
|
|
132
|
+
} catch {
|
|
133
|
+
if (cfg.mode !== 'enforce') return { value, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: false };
|
|
134
|
+
counters.denied++;
|
|
135
|
+
return { value: DENIED, mode: cfg.mode, redactions: {}, total: 0, blocked: false, denied: true };
|
|
136
|
+
}
|
|
137
|
+
}
|