@clear-capabilities/agentic-security-scanner 0.143.0 → 0.144.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/bin/agentic-security.js +477 -47
  3. package/dist/1.index.js +223 -0
  4. package/dist/113.index.js +108 -17
  5. package/dist/144.index.js +163 -0
  6. package/dist/178.index.js +1 -1
  7. package/dist/238.index.js +3 -2
  8. package/dist/265.index.js +191 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/435.index.js +165 -52
  11. package/dist/526.index.js +108 -17
  12. package/dist/552.index.js +97 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/730.index.js +311 -0
  15. package/dist/736.index.js +301 -0
  16. package/dist/824.index.js +7 -0
  17. package/dist/905.index.js +88 -22
  18. package/dist/920.index.js +491 -0
  19. package/dist/970.index.js +109 -0
  20. package/dist/agentic-security.mjs +13 -13
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/dist/calibration-seed.json +2 -0
  23. package/package.json +19 -11
  24. package/src/dataflow/index.js +18 -0
  25. package/src/dataflow/privacy-catalog.js +290 -0
  26. package/src/dataflow/privacy-deep-walker.js +515 -0
  27. package/src/dataflow/privacy-governance.js +126 -0
  28. package/src/dataflow/privacy-inventory.js +154 -0
  29. package/src/dataflow/privacy-sink-policy.js +125 -0
  30. package/src/dataflow/privacy-taint.js +115 -54
  31. package/src/dataflow/privacy-taxonomy.js +233 -0
  32. package/src/discovery/disprove.js +7 -3
  33. package/src/discovery/hunter.js +9 -5
  34. package/src/discovery/index.js +2 -2
  35. package/src/discovery/llm-invoke.js +69 -13
  36. package/src/egress/audit.js +147 -0
  37. package/src/egress/policy.js +313 -0
  38. package/src/egress/redact.js +180 -0
  39. package/src/engine.js +575 -288
  40. package/src/fix/apply-fix-service.js +403 -0
  41. package/src/fix/approver-registry.js +157 -0
  42. package/src/llm-validator/index.js +86 -9
  43. package/src/llm-validator/model-status.js +66 -0
  44. package/src/mcp/tools.js +157 -50
  45. package/src/pipeline/analyzer-supervisor.js +93 -0
  46. package/src/pipeline/analyzer-worker.js +26 -0
  47. package/src/pipeline/annotator-runner.js +33 -0
  48. package/src/pipeline/assurance-mode.js +91 -0
  49. package/src/pipeline/cascade-worker-pool.js +172 -0
  50. package/src/pipeline/cascade-worker.js +43 -0
  51. package/src/pipeline/coverage-ledger.js +0 -0
  52. package/src/pipeline/detector-runner.js +51 -0
  53. package/src/pipeline/enrichment-completion.js +58 -0
  54. package/src/pipeline/evidence-provenance.js +91 -0
  55. package/src/pipeline/finding-schema.js +101 -0
  56. package/src/pipeline/legacy-compat.js +101 -0
  57. package/src/pipeline/producer-collector.js +48 -0
  58. package/src/pipeline/producer-registry.js +112 -0
  59. package/src/pipeline/scan-health.js +144 -0
  60. package/src/posture/CLAUDE.md +2 -0
  61. package/src/posture/accuracy-scorecard.js +96 -1
  62. package/src/posture/adversary-agent.js +15 -3
  63. package/src/posture/artifact-registry.js +217 -0
  64. package/src/posture/auditor-walkthrough.js +70 -8
  65. package/src/posture/calibration-feedback.js +201 -0
  66. package/src/posture/calibration-seed.json +2 -0
  67. package/src/posture/calibration.js +25 -0
  68. package/src/posture/compliance-evidence-signing.js +131 -0
  69. package/src/posture/compliance-policy.js +314 -17
  70. package/src/posture/custom-rules.js +36 -0
  71. package/src/posture/deterministic.js +8 -1
  72. package/src/posture/encryption-provider.js +205 -0
  73. package/src/posture/evidence-grade-wording.js +71 -0
  74. package/src/posture/fix-history.js +113 -19
  75. package/src/posture/fix-honesty-gate.js +47 -6
  76. package/src/posture/fix-verify.js +56 -7
  77. package/src/posture/fleet.js +0 -0
  78. package/src/posture/flow-narration.js +7 -2
  79. package/src/posture/legal-hold.js +140 -0
  80. package/src/posture/llm-redteam.js +10 -1
  81. package/src/posture/material-change.js +90 -0
  82. package/src/posture/policy-bundle.js +274 -0
  83. package/src/posture/privacy-framework.js +33 -6
  84. package/src/posture/production-feedback.js +179 -0
  85. package/src/posture/retention-policy.js +132 -0
  86. package/src/posture/risk-dollars.js +216 -26
  87. package/src/posture/scan-checkpoint.js +176 -31
  88. package/src/posture/state-dir.js +36 -1
  89. package/src/posture/state-lifecycle-report.js +77 -0
  90. package/src/posture/suppressions.js +59 -3
  91. package/src/privacy/ir-adapter.js +380 -0
  92. package/src/report/index.js +51 -2
  93. package/src/report/oscal.js +7 -2
  94. package/src/sast/cpp.js +3 -14
  95. package/src/sca/llm-function-extract.js +6 -0
@@ -13,6 +13,8 @@
13
13
  //
14
14
  // Fail-closed: any LLM error → template fallback, never a missing field.
15
15
 
16
+ import { evaluateEgress } from '../egress/policy.js';
17
+
16
18
  const TEMPLATES = {
17
19
  'sql-injection': (f) =>
18
20
  `An unauthenticated attacker sends a crafted request containing UNION-style SQL syntax in the ${f.source?.variable || 'tainted'} field. The server's database driver executes the injected query verbatim, returning rows from any table the connection has read access to. Typical impact: full table dump of users (emails, password hashes), bypass of authentication via boolean-blind exfiltration. If the DB role has write privileges, the attacker can also INSERT/UPDATE arbitrary rows. Recovery cost: incident response, customer notification, password reset, regulatory reporting if PII leaked.`,
@@ -60,9 +62,12 @@ function _renderTemplate(f) {
60
62
 
61
63
  // Optional LLM call. Disabled by default; opt-in via env. Falls back to the
62
64
  // template on any error.
63
- async function _renderLlm(f) {
65
+ async function _renderLlm(f, scanRoot) {
64
66
  const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
65
67
  if (!endpoint) return null;
68
+ // FR-601: evaluated before the prompt below is built.
69
+ const egressDecision = evaluateEgress({ scanRoot, purpose: 'flow-narration', endpoint });
70
+ if (!egressDecision.allowed) return null;
66
71
  const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
67
72
  const headers = { 'Content-Type': 'application/json' };
68
73
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
@@ -101,7 +106,7 @@ export async function annotateNarration(findings, opts = {}) {
101
106
  f.narration = null;
102
107
  continue;
103
108
  }
104
- let text = useLlm ? await _renderLlm(f) : null;
109
+ let text = useLlm ? await _renderLlm(f, opts.scanRoot) : null;
105
110
  if (!text) text = _renderTemplate(f);
106
111
  f.narration = text;
107
112
  }
@@ -0,0 +1,140 @@
1
+ // FR-707 (assurance-hardening PRD): "Support legal hold and policy-
2
+ // authorized retention exceptions | Legal hold is identity-bound, reasoned,
3
+ // time-bounded where applicable, and auditable."
4
+ //
5
+ // A third instance of the recurring {owner, reason, expires_at} exception
6
+ // shape this codebase already uses twice — `posture/suppressions.js`'s
7
+ // pro-tier exception (scoped to a FINDING) and `posture/compliance-policy.js`'s
8
+ // structured `not-applicable` (scoped to a COMPLIANCE CONTROL). Per D-0025,
9
+ // these are deliberately distinct mechanisms serving different subjects, not
10
+ // one shared module — this file is the third subject: a STATE ARTIFACT.
11
+ //
12
+ // Field naming matches the existing two schemas' snake_case convention
13
+ // (`owner`, `reason`, `expires_at`) rather than inventing a fourth style.
14
+ //
15
+ // identity-bound -> `owner` (required, who placed the hold and is
16
+ // accountable for lifting it)
17
+ // reasoned -> `reason` (required — "we might need this later" is
18
+ // not a reason; same discipline suppressions.js
19
+ // already enforces for its own exceptions)
20
+ // time-bounded
21
+ // where applicable -> `expires_at` is OPTIONAL: a null/absent value is
22
+ // an INDEFINITE hold, which the acceptance
23
+ // criterion's own "where applicable" phrase
24
+ // explicitly allows (a genuine legal matter may have
25
+ // no known end date) — an ISO date value behaves
26
+ // exactly like FR-506/FR-1004's own expiring
27
+ // exceptions: once past, the hold is no longer
28
+ // active and the artifact is exposed to its normal
29
+ // retention TTL again.
30
+ // auditable -> persisted as a single JSON array under
31
+ // `.agentic-security/legal-holds.json` (itself
32
+ // registered as operator-config — an operator/legal
33
+ // team's own input, never scanner-written from scan
34
+ // results), readable via `listLegalHolds`.
35
+ //
36
+ // Consulted from TWO places, not one: `retention-policy.js#findExpiredArtifacts`
37
+ // (defense in depth for any caller reaching it directly) AND `cmdReset`
38
+ // itself for its PLAIN (non-`--expired`) path, which deletes every
39
+ // registered 'generated' artifact unconditionally and would otherwise blow
40
+ // through a hold that only gated TTL expiry.
41
+
42
+ import * as fs from 'node:fs';
43
+ import { statePath, safeWriteState } from './state-dir.js';
44
+ import { isRegisteredArtifact } from './artifact-registry.js';
45
+
46
+ export const LEGAL_HOLD_FILE = 'legal-holds.json';
47
+
48
+ function _loadRaw(scanRoot) {
49
+ let fp;
50
+ try { fp = statePath(scanRoot, LEGAL_HOLD_FILE); } catch { return []; }
51
+ let raw;
52
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { return []; }
53
+ try {
54
+ const doc = JSON.parse(raw);
55
+ return Array.isArray(doc) ? doc : [];
56
+ } catch { return []; }
57
+ }
58
+
59
+ /**
60
+ * All legal holds ever recorded for this project, malformed entries
61
+ * dropped rather than throwing. Never filters by expiry — see
62
+ * `isUnderHold`/`listLegalHolds` for that.
63
+ */
64
+ export function loadLegalHolds(scanRoot) {
65
+ return _loadRaw(scanRoot).filter(h => h && typeof h === 'object' && typeof h.artifact === 'string' && typeof h.owner === 'string' && typeof h.reason === 'string');
66
+ }
67
+
68
+ /**
69
+ * Is `artifactName` currently protected by an active (non-expired) hold?
70
+ * Returns the matching hold record, or null. Multiple holds on the same
71
+ * artifact are permitted (e.g. two independent legal matters); the first
72
+ * still-active one found is returned.
73
+ */
74
+ export function isUnderHold(artifactName, holds, now = Date.now()) {
75
+ for (const h of holds) {
76
+ if (h.artifact !== artifactName) continue;
77
+ if (!h.expires_at) return h; // indefinite hold — always active
78
+ const t = Date.parse(h.expires_at);
79
+ if (!Number.isFinite(t) || t >= now) return h;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Active (non-expired) holds only, unless `includeExpired`. For
86
+ * `legal-hold list` / auditing.
87
+ */
88
+ export function listLegalHolds(scanRoot, { includeExpired = false, now = Date.now() } = {}) {
89
+ const holds = loadLegalHolds(scanRoot);
90
+ if (includeExpired) return holds;
91
+ return holds.filter(h => {
92
+ if (!h.expires_at) return true;
93
+ const t = Date.parse(h.expires_at);
94
+ return !Number.isFinite(t) || t >= now;
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Add a legal hold. Validates identity-bound + reasoned up front (both
100
+ * required, non-empty) and that `expires_at`, if given, is a parseable
101
+ * date in the future — an already-expired hold would be a hold that
102
+ * protects nothing, which is never a legitimate request. `artifact` must
103
+ * name a real registered artifact (artifact-registry.js) — a hold on an
104
+ * unrecognised name can never protect anything and almost always means a
105
+ * typo. Returns `{ok:true, hold}` or `{ok:false, reason}`; never throws.
106
+ */
107
+ export function addLegalHold(scanRoot, { artifact, owner, reason, expires_at } = {}) {
108
+ if (!artifact || typeof artifact !== 'string') return { ok: false, reason: '--artifact is required' };
109
+ if (!isRegisteredArtifact(artifact)) return { ok: false, reason: `"${artifact}" is not a registered state artifact` };
110
+ if (!owner || typeof owner !== 'string') return { ok: false, reason: '--owner is required (identity-bound)' };
111
+ if (!reason || typeof reason !== 'string') return { ok: false, reason: '--reason is required (reasoned)' };
112
+ if (expires_at) {
113
+ const t = Date.parse(expires_at);
114
+ if (!Number.isFinite(t)) return { ok: false, reason: 'expires_at must be a parseable date' };
115
+ if (t < Date.now()) return { ok: false, reason: 'expires_at is in the past — a hold that already expired protects nothing' };
116
+ }
117
+ const hold = { artifact, owner, reason, expires_at: expires_at || null, created_at: new Date().toISOString() };
118
+ const holds = _loadRaw(scanRoot);
119
+ holds.push(hold);
120
+ const fp = statePath(scanRoot, LEGAL_HOLD_FILE);
121
+ if (!safeWriteState(fp, JSON.stringify(holds, null, 2) + '\n')) {
122
+ return { ok: false, reason: 'state writes are disabled (--no-state) or this is not a safe state directory' };
123
+ }
124
+ return { ok: true, hold };
125
+ }
126
+
127
+ /**
128
+ * Remove every hold on `artifact` (lifting a hold, not letting it expire).
129
+ * Returns the number removed. A no-op (0) if none existed — never an error.
130
+ */
131
+ export function removeLegalHold(scanRoot, artifact) {
132
+ const holds = _loadRaw(scanRoot);
133
+ const remaining = holds.filter(h => !(h && h.artifact === artifact));
134
+ const removedCount = holds.length - remaining.length;
135
+ if (removedCount > 0) {
136
+ const fp = statePath(scanRoot, LEGAL_HOLD_FILE);
137
+ safeWriteState(fp, JSON.stringify(remaining, null, 2) + '\n');
138
+ }
139
+ return removedCount;
140
+ }
@@ -13,6 +13,7 @@
13
13
  // strategies (encoding/role-play/authority/etc.), severity-graded report.
14
14
 
15
15
  import { RED_TEAM_PROMPTS, ATTACK_STRATEGIES, PLUGIN_SEVERITY, categorizePrompts, pluginCoverage } from './llm-redteam-prompts.js';
16
+ import { evaluateEgress } from '../egress/policy.js';
16
17
 
17
18
  // ─── STATIC mode ─────────────────────────────────────────────────────────
18
19
  // Scan the repo for prompt files / system-prompt strings and check whether
@@ -173,8 +174,16 @@ function judgeResponse(response, expectedRejection) {
173
174
  * opts: { endpoint, apiKey?, categories?, strategies?, concurrency? }
174
175
  * Returns { results: [...], summary: {...} } */
175
176
  export async function runActiveRedteam(opts = {}) {
176
- const { endpoint, apiKey, categories, strategies, concurrency = 4 } = opts;
177
+ const { endpoint, apiKey, categories, strategies, concurrency = 4, scanRoot } = opts;
177
178
  if (!endpoint) throw new Error('endpoint URL required');
179
+ // FR-601: one decision for the whole batch (same endpoint for every
180
+ // prompt) — evaluated before any prompt in the corpus is sent, not
181
+ // per-prompt, since a policy verdict on an endpoint does not vary by
182
+ // which static red-team prompt is about to be sent to it.
183
+ const egressDecision = evaluateEgress({ scanRoot, purpose: 'llm-redteam', endpoint });
184
+ if (!egressDecision.allowed) {
185
+ throw new Error(`egress policy denied this call: ${egressDecision.reason}`);
186
+ }
178
187
 
179
188
  let prompts = RED_TEAM_PROMPTS;
180
189
  if (Array.isArray(categories) && categories.length) {
@@ -11,6 +11,7 @@
11
11
  // or the command runner) collects the unified diff and feeds hunks into classifyHunk.
12
12
 
13
13
  import * as cp from 'node:child_process';
14
+ import { loadPrivacyTaxonomy } from '../dataflow/privacy-taxonomy.js';
14
15
 
15
16
  // Patterns that fire on the deletion side (auth/check removed).
16
17
  const DEL_PATTERNS = [
@@ -26,6 +27,21 @@ const DEL_PATTERNS = [
26
27
  kind: 'security-middleware-removed', sev: 'medium', evidence: 'Security middleware removed' },
27
28
  ];
28
29
 
30
+ // FR-307: a new/modified PII/PHI-shaped field is one of the named
31
+ // high-impact categories ("PII" — see HIGH_IMPACT_CATEGORY_OF_KIND below).
32
+ // Reuses dataflow/privacy-taxonomy.js's FR-402 field-name vocabulary
33
+ // (built-in defaults only — this is a coarse architectural-risk signal,
34
+ // not the authoritative privacy classification egress/redact.js and
35
+ // dataflow/privacy-taint.js already own) rather than inventing a second,
36
+ // parallel PII field-name list that could drift from the first.
37
+ const _PII_FIELD_NAMES = [
38
+ ...(loadPrivacyTaxonomy(null).taxonomy.PII?.patterns || []),
39
+ ...(loadPrivacyTaxonomy(null).taxonomy.PHI?.patterns || []),
40
+ ];
41
+ const PII_FIELD_RE = _PII_FIELD_NAMES.length
42
+ ? new RegExp('(?:' + _PII_FIELD_NAMES.join('|') + ')\\s*[:=]', 'i')
43
+ : /(?!)/; // never matches — degrades safely if the taxonomy import ever returns nothing
44
+
29
45
  // Patterns that fire on the addition side (new attack surface introduced).
30
46
  const ADD_PATTERNS = [
31
47
  { re: /\b(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|all)\s*\(\s*['"][^'"]+['"]/,
@@ -56,8 +72,38 @@ const ADD_PATTERNS = [
56
72
  kind: 'pipeline-floating-tag', sev: 'medium', evidence: 'GitHub Actions step pinned to floating tag' },
57
73
  { re: /\bprivileged\s*:\s*true\b/i,
58
74
  kind: 'new-iac-privilege', sev: 'high', evidence: 'Container/pod marked privileged' },
75
+ // FR-307: crypto, PII, and schema — the three named high-impact
76
+ // categories the pre-existing kind taxonomy above did not yet cover.
77
+ { re: /\b(?:md5|sha1)\s*\(/i,
78
+ kind: 'weak-crypto-added', sev: 'high', evidence: 'Weak/deprecated hash primitive (MD5/SHA-1) referenced' },
79
+ { re: /createCipheriv\s*\(\s*['"`](?:des|des-ede3|rc4|bf|rc2)/i,
80
+ kind: 'weak-crypto-added', sev: 'critical', evidence: 'Weak/deprecated cipher algorithm referenced' },
81
+ { re: /\b(?:createCipheriv|createDecipheriv|generateKeyPair(?:Sync)?|crypto\.subtle|jwt\.sign|jsonwebtoken)\s*[.(]/,
82
+ kind: 'crypto-primitive-changed', sev: 'high', evidence: 'Cryptographic primitive or JWT signing usage added or changed' },
83
+ { re: PII_FIELD_RE,
84
+ kind: 'new-pii-field', sev: 'high', evidence: 'PII/PHI-shaped field introduced or modified' },
85
+ { re: /\b(?:ALTER\s+TABLE|CREATE\s+TABLE|DROP\s+(?:TABLE|COLUMN)|ADD\s+COLUMN)\b/i,
86
+ kind: 'schema-change', sev: 'medium', evidence: 'Database schema change (DDL)' },
59
87
  ];
60
88
 
89
+ // FR-307: which `kind`s constitute one of the PRD's named high-impact
90
+ // change classes ("Auth, authZ, crypto, PII, schema, infrastructure
91
+ // privilege, and public API changes cannot auto-apply without approval
92
+ // evidence"). A finding whose kind is NOT in this map is still scored for
93
+ // severity as before, but does not by itself trigger the approval gate —
94
+ // only these specific, named categories do.
95
+ export const HIGH_IMPACT_CATEGORY_OF_KIND = {
96
+ 'auth-removed': 'auth',
97
+ 'priv-from-body': 'authZ',
98
+ 'new-pii-field': 'pii',
99
+ 'weak-crypto-added': 'crypto',
100
+ 'crypto-primitive-changed': 'crypto',
101
+ 'schema-change': 'schema',
102
+ 'new-iac-privilege': 'infra-privilege',
103
+ 'pipeline-perms-widened': 'infra-privilege',
104
+ 'new-endpoint': 'public-api',
105
+ };
106
+
61
107
  // Routine / low-risk patterns (NEVER classify higher than 'low').
62
108
  const ROUTINE_PATTERNS = [
63
109
  /^\+\s*\/\//, // adding a comment
@@ -161,3 +207,47 @@ export function classifyGitDiff(rootDir, ref) {
161
207
  }
162
208
  return classifyDiff(out);
163
209
  }
210
+
211
+ /**
212
+ * FR-307: classify a candidate FIX (not a committed diff) for high-impact
213
+ * change classes. `files` is `{relPath: {before, after}}` — exactly the
214
+ * before/after content shape fix/apply-fix-service.js already has on hand
215
+ * for every candidate write, so no git invocation or unified-diff text is
216
+ * needed here. A crude but sufficient line-set diff (lines only in `after`
217
+ * are additions, lines only in `before` are deletions) is enough for
218
+ * PATTERN matching — classifyHunk operates per-line regardless of hunk
219
+ * boundaries, so an imprecise line-diff still detects the same risk
220
+ * signals a real diff would.
221
+ *
222
+ * Returns the same shape classifyDiff() does, plus `highImpactCategories`
223
+ * — the deduplicated, sorted list of named categories
224
+ * (auth/authZ/crypto/pii/schema/infra-privilege/public-api) any finding in
225
+ * this candidate belongs to. An empty list means nothing in the candidate
226
+ * matched one of the PRD's named high-impact classes — the approval gate
227
+ * in apply-fix-service.js is a no-op in that case, same
228
+ * restricts-nothing-until-triggered default this codebase's other policy
229
+ * gates (egress/policy.js, dataflow/privacy-sink-policy.js) already follow.
230
+ */
231
+ export function classifyFixMaterialRisk(files) {
232
+ const findings = [];
233
+ for (const [file, pair] of Object.entries(files || {})) {
234
+ const before = String(pair?.before ?? '');
235
+ const after = String(pair?.after ?? '');
236
+ if (before === after) continue;
237
+ const beforeLines = before.split('\n');
238
+ const afterLines = after.split('\n');
239
+ const beforeSet = new Set(beforeLines);
240
+ const afterSet = new Set(afterLines);
241
+ const add = afterLines.filter(l => !beforeSet.has(l));
242
+ const del = beforeLines.filter(l => !afterSet.has(l));
243
+ if (!add.length && !del.length) continue;
244
+ findings.push(...classifyHunk({ file, add, del }));
245
+ }
246
+ const result = summarize(findings);
247
+ const categories = new Set();
248
+ for (const f of result.findings) {
249
+ const cat = HIGH_IMPACT_CATEGORY_OF_KIND[f.kind];
250
+ if (cat) categories.add(cat);
251
+ }
252
+ return { ...result, highImpactCategories: [...categories].sort() };
253
+ }
@@ -0,0 +1,274 @@
1
+ // Signed, portable policy bundles (assurance-hardening PRD FR-1001).
2
+ //
3
+ // "Support signed portable policy bundles with organization, repository, and
4
+ // environment inheritance" | "Tampered or expired policy is rejected;
5
+ // effective policy is explainable."
6
+ //
7
+ // WHY REUSE evidence-bundle.js's Ed25519 SCHEME, NOT integrity.js's HMAC
8
+ // -------------------------------------------------------------------------
9
+ // A policy bundle is authored by one party (typically a central security
10
+ // team, "the organization") and distributed to many repositories that never
11
+ // had the signing key. integrity.js's per-install symmetric HMAC is
12
+ // tamper-evidence for the SAME install that wrote it — a different install
13
+ // verifying a bundle it didn't sign is exactly the case that scheme cannot
14
+ // serve. evidence-bundle.js already solved this for findings: Ed25519,
15
+ // verify with only the public key. This module reuses that primitive
16
+ // directly (canonicalisation shape, allowlist-signed-fields discipline,
17
+ // exclusive-create key generation) rather than inventing a third signing
18
+ // mechanism, per this session's own established "survey before building a
19
+ // parallel primitive" practice (see D-0023/D-0025 for the same discipline
20
+ // applied to identity and exception mechanisms respectively).
21
+ //
22
+ // EA-03 LESSON, APPLIED HERE FROM THE START
23
+ // -------------------------------------------------------------------------
24
+ // evidence-bundle.js's own history (see its EA-03 comment) is the reason
25
+ // `canonicalPolicyBytes` signs an explicit ALLOWLIST and `verifyPolicyBundle`
26
+ // REJECTS any top-level key outside it: a bundle with a field stapled on
27
+ // after signing must fail verification, not silently pass with the addition
28
+ // unverified. Getting this right on the first pass — rather than discovering
29
+ // the gap the way evidence-bundle.js did — is the entire point of writing it
30
+ // down here.
31
+ //
32
+ // SCOPE — WHAT THIS MODULE DOES NOT DO
33
+ // -------------------------------------------------------------------------
34
+ // It does not invent a policy SCHEMA — `policy` is an opaque object; whatever
35
+ // keys a bundle carries (egress rules, severity floors, approved providers,
36
+ // anything else this repo already treats as policy) are merged the same way.
37
+ // It does not implement deep/recursive merging — inheritance is shallow,
38
+ // top-level-key override (organization -> repository -> environment, most
39
+ // specific wins per key), matching how every other flat policy config file
40
+ // in this repo (egress-policy.yml, compliance-severity-policy.json, …) is
41
+ // already shaped. A future consumer needing nested-key inheritance is new,
42
+ // separate scope, not assumed here.
43
+
44
+ import * as fs from 'node:fs';
45
+ import * as os from 'node:os';
46
+ import * as path from 'node:path';
47
+ import * as crypto from 'node:crypto';
48
+ import { ensureKeyPair as ensureKeyPairAt } from './evidence-bundle.js';
49
+ import { statePath } from './state-dir.js';
50
+
51
+ export const POLICY_BUNDLE_SCHEMA = 'agentic-security/policy-bundle@1';
52
+
53
+ export const SCOPES = ['organization', 'repository', 'environment'];
54
+ // Inheritance order — later entries override earlier ones, per top-level key.
55
+ const INHERITANCE_ORDER = ['organization', 'repository', 'environment'];
56
+
57
+ function keyDir() {
58
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
59
+ return path.join(xdg, 'agentic-security', 'policy-bundles');
60
+ }
61
+
62
+ /** A SEPARATE Ed25519 keypair from evidence-bundle.js's finding-attestation
63
+ * key — signing "this policy is authentic" and "this finding really came
64
+ * from a scan run by me" are different trust domains and must not share a
65
+ * key. Reuses evidence-bundle.js's hardened, race-safe generation logic
66
+ * directly (its exclusive-create collision handling is the kind of code
67
+ * worth NOT re-implementing) rather than duplicating it — the separation
68
+ * that matters is the DIRECTORY (`policy-bundles/`, isolated from
69
+ * evidence-bundle.js's own key dir), not the leaf filenames, which are
70
+ * `attest-key.pem`/`attest-key.pub.pem` regardless of caller (a
71
+ * evidence-bundle.js internal, not overridable here) — a readability wart,
72
+ * not a security one, since the directory is what prevents key reuse. */
73
+ export function ensurePolicyKeyPair(dir = keyDir()) {
74
+ return ensureKeyPairAt(dir);
75
+ }
76
+
77
+ /**
78
+ * Deterministic JSON, keys sorted at every level — identical algorithm to
79
+ * evidence-bundle.js's canonicalJson (duplicated rather than imported: it is
80
+ * a pure, three-line function, and importing it would couple this module's
81
+ * signature format to evidence-bundle.js's internals for no real benefit).
82
+ */
83
+ function canonicalJson(value) {
84
+ if (value === null || typeof value !== 'object') return JSON.stringify(value ?? null);
85
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
86
+ const keys = Object.keys(value).sort();
87
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
88
+ }
89
+
90
+ // The complete set of fields a legitimately-built bundle carries, BEFORE
91
+ // signing. This is the signed allowlist AND (in verifyPolicyBundle) the
92
+ // complete set of keys a signed bundle is permitted to have alongside
93
+ // `signature` — see the EA-03 note above for why both matter.
94
+ const BUNDLE_FIELDS = ['schema', 'scope', 'policy', 'issuedAt', 'expiresAt'];
95
+ const BUNDLE_TOP_LEVEL_KEYS = new Set([...BUNDLE_FIELDS, 'signature']);
96
+
97
+ export function canonicalPolicyBytes(bundle) {
98
+ const signed = {};
99
+ for (const k of BUNDLE_FIELDS) signed[k] = bundle[k] ?? null;
100
+ return Buffer.from(canonicalJson(signed), 'utf8');
101
+ }
102
+
103
+ /**
104
+ * Build an unsigned bundle. `policy` is caller-supplied and opaque — this
105
+ * module does not validate its shape, only its provenance and freshness.
106
+ */
107
+ export function buildPolicyBundle(scope, policy, { issuedAt, expiresAt } = {}) {
108
+ if (!SCOPES.includes(scope)) return null;
109
+ if (!policy || typeof policy !== 'object' || Array.isArray(policy)) return null;
110
+ return {
111
+ schema: POLICY_BUNDLE_SCHEMA,
112
+ scope,
113
+ policy,
114
+ issuedAt: issuedAt ?? new Date().toISOString(),
115
+ expiresAt: expiresAt ?? null,
116
+ };
117
+ }
118
+
119
+ /** Sign a bundle. Returns a new object; the input is not mutated. */
120
+ export function signPolicyBundle(bundle, privateKeyPem) {
121
+ const sig = crypto.sign(null, canonicalPolicyBytes(bundle), privateKeyPem);
122
+ return {
123
+ ...bundle,
124
+ signature: { algorithm: 'ed25519', canonicalisation: POLICY_BUNDLE_SCHEMA, value: sig.toString('base64') },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Verify a bundle: signature authenticity AND freshness. Never throws — a
130
+ * malformed or tampered bundle from an untrusted source (a repo checking out
131
+ * whatever an org distributed) is an expected input, not exceptional.
132
+ *
133
+ * @returns {{ok: boolean, reason: string|null}}
134
+ */
135
+ export function verifyPolicyBundle(bundle, publicKeyPem, { now = Date.now() } = {}) {
136
+ if (!bundle || typeof bundle !== 'object') return { ok: false, reason: 'bundle is not an object' };
137
+ if (bundle.schema !== POLICY_BUNDLE_SCHEMA) return { ok: false, reason: `unrecognised schema: ${bundle.schema}` };
138
+ if (!SCOPES.includes(bundle.scope)) return { ok: false, reason: `unrecognised scope: ${bundle.scope}` };
139
+ const unknownKeys = Object.keys(bundle).filter(k => !BUNDLE_TOP_LEVEL_KEYS.has(k));
140
+ if (unknownKeys.length) {
141
+ return { ok: false, reason: `unrecognised top-level key(s) not covered by the signature: ${unknownKeys.join(', ')}` };
142
+ }
143
+ const sig = bundle.signature;
144
+ if (!sig?.value) return { ok: false, reason: 'bundle is unsigned' };
145
+ if (sig.algorithm !== 'ed25519') return { ok: false, reason: `unsupported algorithm: ${sig.algorithm}` };
146
+ if (!publicKeyPem) return { ok: false, reason: 'no public key supplied' };
147
+ let sigOk = false;
148
+ try {
149
+ sigOk = crypto.verify(null, canonicalPolicyBytes(bundle), publicKeyPem, Buffer.from(sig.value, 'base64'));
150
+ } catch (e) {
151
+ return { ok: false, reason: `verification error: ${e.message}` };
152
+ }
153
+ if (!sigOk) return { ok: false, reason: 'signature does not match the bundle contents — it was modified after signing' };
154
+ if (bundle.expiresAt) {
155
+ const exp = Date.parse(bundle.expiresAt);
156
+ if (!Number.isFinite(exp)) return { ok: false, reason: `expiresAt is not a valid date: ${bundle.expiresAt}` };
157
+ if (exp < now) return { ok: false, reason: `policy expired on ${bundle.expiresAt}` };
158
+ }
159
+ return { ok: true, reason: null };
160
+ }
161
+
162
+ /**
163
+ * Merge verified bundles into one effective policy, in inheritance order
164
+ * (organization -> repository -> environment; most specific wins per key).
165
+ * A tampered or expired bundle is EXCLUDED from the merge (rejected, not
166
+ * silently dropped — its scope and reason are reported so a rejection is
167
+ * visible, not just absent). `provenance[key]` names which scope's bundle
168
+ * last set that key — the "effective policy is explainable" half of the
169
+ * acceptance criterion.
170
+ *
171
+ * @param {Array<{scope: string, bundle: object}>} entries
172
+ * @param {string} publicKeyPem
173
+ * @returns {{effective: object, provenance: Record<string,string>,
174
+ * accepted: string[], rejected: Array<{scope:string, reason:string}>}}
175
+ */
176
+ export function resolveEffectivePolicy(entries, publicKeyPem, { now = Date.now() } = {}) {
177
+ const byScope = new Map();
178
+ for (const e of entries || []) {
179
+ if (e && SCOPES.includes(e.scope)) byScope.set(e.scope, e.bundle);
180
+ }
181
+ const effective = {};
182
+ const provenance = {};
183
+ const accepted = [];
184
+ const rejected = [];
185
+ for (const scope of INHERITANCE_ORDER) {
186
+ const bundle = byScope.get(scope);
187
+ if (!bundle) continue;
188
+ const v = verifyPolicyBundle(bundle, publicKeyPem, { now });
189
+ if (!v.ok) { rejected.push({ scope, reason: v.reason }); continue; }
190
+ accepted.push(scope);
191
+ for (const [k, val] of Object.entries(bundle.policy)) {
192
+ effective[k] = val;
193
+ provenance[k] = scope;
194
+ }
195
+ }
196
+ return { effective, provenance, accepted, rejected };
197
+ }
198
+
199
+ /**
200
+ * Load whichever policy bundle files exist under
201
+ * `.agentic-security/policy-bundles/{organization,repository,environment}.json`.
202
+ * Degrades gracefully — a missing directory or missing/malformed individual
203
+ * file is simply absent from the result, never thrown. Read-first-in-try/
204
+ * catch throughout (no existsSync-then-readFileSync — D-0012/D-0022).
205
+ */
206
+ export function loadPolicyBundles(scanRoot) {
207
+ if (!scanRoot) return [];
208
+ const entries = [];
209
+ for (const scope of SCOPES) {
210
+ let fp;
211
+ try { fp = statePath(scanRoot, 'policy-bundles', `${scope}.json`); } catch { continue; }
212
+ let raw;
213
+ try { raw = fs.readFileSync(fp, 'utf8'); } catch { continue; }
214
+ try {
215
+ const bundle = JSON.parse(raw);
216
+ if (bundle && typeof bundle === 'object') entries.push({ scope, bundle });
217
+ } catch { /* malformed — skip, do not throw */ }
218
+ }
219
+ return entries;
220
+ }
221
+
222
+ /**
223
+ * Read the operator-supplied public key an org distributes alongside its
224
+ * bundles, from `.agentic-security/policy-bundle-public-key.pem`. Returns
225
+ * null (not a throw) if absent or unreadable — resolution then rejects
226
+ * every bundle for lack of a key, same as any other "no public key
227
+ * supplied" case in verifyPolicyBundle.
228
+ */
229
+ export function loadPolicyPublicKey(scanRoot) {
230
+ if (!scanRoot) return null;
231
+ let fp;
232
+ try { fp = statePath(scanRoot, 'policy-bundle-public-key.pem'); } catch { return null; }
233
+ try { return fs.readFileSync(fp, 'utf8'); } catch { return null; }
234
+ }
235
+
236
+ /**
237
+ * FR-1006 ("policy drift"): does this repository's LOCALLY resolved effective
238
+ * policy diverge from what the organization-scope bundle alone specifies —
239
+ * and is anything the repo is relying on actually being rejected (tampered
240
+ * or expired) rather than enforced? Both are GOVERNANCE gaps, distinct from
241
+ * risk findings, which is the exact distinction FR-1006's acceptance
242
+ * criterion asks fleet output to make.
243
+ *
244
+ * No bundles configured at all is a no-op (`null`), matching this session's
245
+ * established convention: drift can only be reported relative to a
246
+ * baseline, and a repo with no organization bundle has no baseline to drift
247
+ * from.
248
+ *
249
+ * @returns {{accepted: string[], rejected: Array<{scope,reason}>,
250
+ * overrides: Array<{key, organizationValue, effectiveValue, overriddenBy}>}
251
+ * | null}
252
+ */
253
+ export function computePolicyDrift(scanRoot) {
254
+ const entries = loadPolicyBundles(scanRoot);
255
+ if (!entries.length) return null;
256
+ const publicKeyPem = loadPolicyPublicKey(scanRoot);
257
+ const { effective, provenance, accepted, rejected } = resolveEffectivePolicy(entries, publicKeyPem);
258
+
259
+ const overrides = [];
260
+ const orgEntry = entries.find(e => e.scope === 'organization');
261
+ if (orgEntry && accepted.includes('organization')) {
262
+ for (const [key, organizationValue] of Object.entries(orgEntry.bundle.policy || {})) {
263
+ const overriddenBy = provenance[key];
264
+ if (overriddenBy && overriddenBy !== 'organization' && !_deepEqual(effective[key], organizationValue)) {
265
+ overrides.push({ key, organizationValue, effectiveValue: effective[key], overriddenBy });
266
+ }
267
+ }
268
+ }
269
+ return { accepted, rejected, overrides };
270
+ }
271
+
272
+ function _deepEqual(a, b) {
273
+ return JSON.stringify(a) === JSON.stringify(b);
274
+ }