@clear-capabilities/agentic-security-scanner 0.144.0 → 0.147.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/CHANGELOG.md +390 -0
- package/bin/agentic-security.js +3813 -83
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +13 -4
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/2271.index.js +165 -0
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +14 -6
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +14 -6
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +67 -3
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +29 -7
- package/dist/{449.index.js → 5830.index.js} +78 -14
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +26 -8
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +580 -187
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +23 -10
- package/src/compare.js +6 -1
- package/src/dataflow/CLAUDE.md +2 -2
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +580 -30
- package/src/fix/apply-fix-service.js +1 -0
- package/src/history-scan.js +22 -5
- package/src/ir/CLAUDE.md +2 -1
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/lsp/server.js +49 -2
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +22 -1
- package/src/pipeline/assurance-mode.js +64 -1
- package/src/pipeline/finding-schema.js +8 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +140 -0
- package/src/posture/accuracy-scorecard.js +60 -0
- package/src/posture/artifact-registry.js +76 -0
- package/src/posture/auditor-walkthrough.js +192 -13
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/compliance-policy.js +12 -2
- package/src/posture/cross-repo-memory.js +7 -2
- package/src/posture/fix-history.js +25 -2
- package/src/posture/fix-verify.js +9 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/git-history.js +13 -5
- package/src/posture/material-change.js +21 -2
- package/src/posture/mttr.js +75 -12
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/pre-incident-archaeology.js +39 -7
- package/src/posture/privacy-framework.js +14 -0
- package/src/posture/provenance/ai-authorship.js +68 -0
- package/src/posture/provenance/branch-entry.js +80 -0
- package/src/posture/provenance/cache.js +143 -0
- package/src/posture/provenance/confidence.js +36 -0
- package/src/posture/provenance/coordinator.js +786 -0
- package/src/posture/provenance/dag-walk.js +249 -0
- package/src/posture/provenance/evidence-attribution.js +59 -0
- package/src/posture/provenance/git-evidence.js +310 -0
- package/src/posture/provenance/lifecycle.js +208 -0
- package/src/posture/provenance/missing-control-resolver.js +137 -0
- package/src/posture/provenance/origin-resolver.js +342 -0
- package/src/posture/provenance/predicate-replay.js +133 -0
- package/src/posture/provenance/providers/config.js +39 -0
- package/src/posture/provenance/providers/github.js +62 -0
- package/src/posture/provenance/providers/gitlab.js +58 -0
- package/src/posture/provenance/repo-lineage.js +74 -0
- package/src/posture/provenance/sca-origin.js +139 -0
- package/src/posture/provenance/schema.js +255 -0
- package/src/posture/provenance/transitive-sca.js +147 -0
- package/src/posture/provenance/validate.js +30 -0
- package/src/posture/provenance-evidence-bundle.js +144 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/posture/sbom-diff.js +15 -2
- package/src/posture/secret-history.js +10 -2
- package/src/posture/state-dir.js +38 -14
- package/src/posture/vuln-archaeology.js +8 -2
- package/src/pr-delta.js +25 -4
- package/src/report/index.js +197 -3
- package/src/runScan.js +34 -5
- package/src/sast/rate-limit.js +33 -3
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/src/util/git-hardening.js +128 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// observation-store.js — M5 deliverable #7 (Runtime-Corroborated Digital
|
|
2
|
+
// Twin, runtime-observed half only, "7b"; see runtime-observation.js's own
|
|
3
|
+
// header for why "7a", config-declared edges, is out of scope for this
|
|
4
|
+
// whole sub-project — scoping doc §4.0). FR-505, AC-29. The IMPURE layer:
|
|
5
|
+
// a directory of independently-readable, immutable whole files — one file
|
|
6
|
+
// per adapter IMPORT, never per observation.
|
|
7
|
+
//
|
|
8
|
+
// ── The storage ruling, and the three rejected candidates ───────────────
|
|
9
|
+
//
|
|
10
|
+
// Three existing state-storage shapes in this codebase were each considered
|
|
11
|
+
// and rejected, per the scoping doc's §4.4 (Correction 6):
|
|
12
|
+
//
|
|
13
|
+
// - `graph-snapshot.js`'s GraphSnapshot store is commit-keyed: one file
|
|
14
|
+
// per commit. That keying cannot express "many observation imports per
|
|
15
|
+
// graph entity" — an operator may import several adapter exports
|
|
16
|
+
// (different windows, different environments, a re-import after fixing
|
|
17
|
+
// a malformed source file) against the SAME commit, and commit-keying
|
|
18
|
+
// would collide them onto one file or silently overwrite.
|
|
19
|
+
// - `posture/provenance/lifecycle.js` rewrites its whole document on
|
|
20
|
+
// every update and is registered with a DELIBERATE no-`retentionClass`
|
|
21
|
+
// policy (permanent history, never auto-expired). An observation
|
|
22
|
+
// import is neither: it is not a document that grows by rewrite, and
|
|
23
|
+
// FR-505 requires it follow real retention/reset rules, not stay
|
|
24
|
+
// permanent forever.
|
|
25
|
+
// - `posture/remediation-ledger` (M5 deliverable #6)'s locked, hash-chained JSONL solves a
|
|
26
|
+
// read-fold-validate-write problem — many callers appending to ONE
|
|
27
|
+
// shared ledger, where the fold and the chain both depend on total
|
|
28
|
+
// order — that does not exist here: an import is a single, complete,
|
|
29
|
+
// independently-valid record with no fold step. Worse, its hash chain
|
|
30
|
+
// makes deletion structurally impossible (unlinking the middle of a
|
|
31
|
+
// chain breaks every entry after it), and FR-505 explicitly requires
|
|
32
|
+
// an observation store follow real retention/reset rules — an
|
|
33
|
+
// append-only hash chain and a retention-and-reset requirement are
|
|
34
|
+
// directly opposed.
|
|
35
|
+
//
|
|
36
|
+
// The choice, then: mirror `lineage-snapshots/`'s own directory-of-
|
|
37
|
+
// immutable-files shape, re-keyed from commit -> import. Each import is
|
|
38
|
+
// one whole, self-contained, independently-readable JSON file; nothing
|
|
39
|
+
// here ever rewrites a file in place, and nothing here ever folds two
|
|
40
|
+
// files into one logical state.
|
|
41
|
+
//
|
|
42
|
+
// ── No lock, and why (CORRECTED — final review B2) ─────────────────────
|
|
43
|
+
//
|
|
44
|
+
// Every import is an independent whole file. The only concurrency hazard a
|
|
45
|
+
// lock would address is two writers targeting the SAME file. This
|
|
46
|
+
// module's own header USED TO claim `observationImportId`'s `importedAt`
|
|
47
|
+
// discriminator alone made that impossible — that claim was FALSE:
|
|
48
|
+
// `importedAt` (`new Date().toISOString()`) is millisecond-resolution, so
|
|
49
|
+
// two concurrent `dataflow observations import --yes` invocations sharing
|
|
50
|
+
// adapter/source/environment/window and landing in the same millisecond
|
|
51
|
+
// minted the IDENTICAL import id, and the second write silently clobbered
|
|
52
|
+
// the first while BOTH processes reported success. Live-reproduced by the
|
|
53
|
+
// final review: 5 of 8 concurrent-round trials lost an entire import.
|
|
54
|
+
// Fixed two ways, belt and suspenders: (1) the CALLER
|
|
55
|
+
// (`cmdDataflowObservationsImport`, `bin/agentic-security.js`) now mints
|
|
56
|
+
// `observationImportId` with a fresh random discriminator part, so two
|
|
57
|
+
// invocations can never collide regardless of timing; (2) this module's
|
|
58
|
+
// own write is now ATOMIC (`_writeAtomicSync`, temp-file-then-rename)
|
|
59
|
+
// rather than a bare `writeFileSync`, so even a genuine same-name write
|
|
60
|
+
// race (a caller that skipped the id fix, or two callers racing on a
|
|
61
|
+
// hand-supplied id) can no longer produce a torn file — the worst case is
|
|
62
|
+
// now "one writer's complete content wins," never "a half-written file."
|
|
63
|
+
// There is still no read-fold-validate-write critical section anywhere in
|
|
64
|
+
// this module (contrast the remediation ledger, which locks for exactly
|
|
65
|
+
// that reason) — so there is still nothing for a LOCK specifically to
|
|
66
|
+
// protect; the fix is collision-proof ids plus an atomic write, not a lock.
|
|
67
|
+
//
|
|
68
|
+
// ── `statePath` is called with a STRING LITERAL at every site ──────────
|
|
69
|
+
//
|
|
70
|
+
// `test/artifact-registry-completeness.test.js`'s own `PATTERNS` regexes
|
|
71
|
+
// require a quoted literal as `statePath`'s second argument to detect a
|
|
72
|
+
// call site at all — a variable defeats the guard silently. This is not
|
|
73
|
+
// theoretical: `graph-snapshot.js:36` calls `statePath(scanRoot, HISTORY_DIR)`
|
|
74
|
+
// with a module constant, genuinely escapes the completeness guard today,
|
|
75
|
+
// and is registered in artifact-registry.js only because someone
|
|
76
|
+
// remembered by hand. `OBSERVATION_STORE_DIR` is exported below as a
|
|
77
|
+
// convenience for READERS (display strings, docs) but is deliberately
|
|
78
|
+
// never passed as `statePath`'s second argument anywhere in this file —
|
|
79
|
+
// `observationsDir` uses the literal `'runtime-observations'` directly, so
|
|
80
|
+
// the completeness guard has a real literal to see.
|
|
81
|
+
//
|
|
82
|
+
// ── Encryption is called explicitly, because the registry flag alone
|
|
83
|
+
// enforces nothing ─────────────────────────────────────────────────
|
|
84
|
+
//
|
|
85
|
+
// `confidential: true` on an artifact-registry.js entry is a DECLARATION,
|
|
86
|
+
// not an enforced control (see this sub-project's own scoping-doc
|
|
87
|
+
// Correction 1) — nothing reads that flag automatically at write time.
|
|
88
|
+
// The only two confidential artifacts in this tree before this module
|
|
89
|
+
// (`compliance-evidence.json`/`.md`) each call `maybeEncryptForWrite`/
|
|
90
|
+
// `maybeDecryptForRead` from their own writer (`posture/compliance-
|
|
91
|
+
// policy.js:497`/`:546`) — there is no ambient enforcement mechanism to
|
|
92
|
+
// inherit. `persistObservationImport`/`loadObservationImports`/
|
|
93
|
+
// `loadObservationImport` therefore call those two functions explicitly,
|
|
94
|
+
// exactly like that precedent, so the `confidential: true` this module's
|
|
95
|
+
// artifact-registry.js entry carries is backed by real behavior, not just
|
|
96
|
+
// a claim.
|
|
97
|
+
//
|
|
98
|
+
// ── Key-shape validation happens BEFORE any path.join ───────────────────
|
|
99
|
+
//
|
|
100
|
+
// `loadObservationImport(scanRoot, importId)` validates `importId`'s shape
|
|
101
|
+
// via `importFileName` before it ever builds a path — a disclosed,
|
|
102
|
+
// pre-existing gap this module deliberately does NOT inherit:
|
|
103
|
+
// `graph-snapshot.js`'s own `loadSnapshot(scanRoot, commitKey)` joins its
|
|
104
|
+
// caller-supplied key straight onto the history directory with no shape
|
|
105
|
+
// check at all (`graph-snapshot.js:150-152`), so a crafted `commitKey`
|
|
106
|
+
// could in principle read outside the state directory. Not exploited
|
|
107
|
+
// today (every real caller passes a git commit or a `--against` flag) and
|
|
108
|
+
// deliberately out of scope to fix there — this module's own header
|
|
109
|
+
// exists to record that this is a KNOWN, DISCLOSED asymmetry between the
|
|
110
|
+
// two sibling stores, not an oversight in either.
|
|
111
|
+
|
|
112
|
+
import * as fs from 'node:fs';
|
|
113
|
+
import * as path from 'node:path';
|
|
114
|
+
import * as crypto from 'node:crypto';
|
|
115
|
+
import { statePath, isSafeStateDir, stateWritesEnabled } from '../posture/state-dir.js';
|
|
116
|
+
import { maybeEncryptForWrite, maybeDecryptForRead } from '../posture/encryption-provider.js';
|
|
117
|
+
import { validateRuntimeObservation, RUNTIME_OBSERVATION_ADAPTERS } from './runtime-observation.js';
|
|
118
|
+
// I1 (final review): every other evidence-bearing artifact in this codebase
|
|
119
|
+
// (lineage-graph.json, last-scan.json, the remediation ledger's hash chain)
|
|
120
|
+
// carries tamper-evidence — the observation store did not, so a hand-planted
|
|
121
|
+
// forged import (a fabricated matchMethod/matchConfidence naming a real
|
|
122
|
+
// flow's ids) was indistinguishable from real evidence on read and could
|
|
123
|
+
// launder into the signed graph as genuine runtime corroboration. Reused
|
|
124
|
+
// UNCHANGED — the same generic, filename-agnostic HMAC primitive
|
|
125
|
+
// `lineage-graph.json` itself already uses (confirmed by direct read of
|
|
126
|
+
// this file: no filename baked in anywhere).
|
|
127
|
+
import { signLastScan, verifyLastScan } from '../posture/integrity.js';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The literal top-level directory name under `.agentic-security/`.
|
|
131
|
+
* Exported for readers (display strings, docs) — NEVER pass this as
|
|
132
|
+
* `statePath`'s second argument; see this file's own header for why.
|
|
133
|
+
*/
|
|
134
|
+
export const OBSERVATION_STORE_DIR = 'runtime-observations';
|
|
135
|
+
|
|
136
|
+
export const OBSERVATION_IMPORT_VERSION = '1.0.0';
|
|
137
|
+
|
|
138
|
+
// The closed top-level key set of an ObservationImport record.
|
|
139
|
+
const IMPORT_FIELDS = Object.freeze([
|
|
140
|
+
'id', 'version', 'adapter', 'source', 'environment', 'windowStart', 'windowEnd',
|
|
141
|
+
'importedAt', 'retention', 'observations',
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const _RETENTION_KEYS = ['expiresAt'];
|
|
145
|
+
|
|
146
|
+
function _isPlainObject(v) {
|
|
147
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _isNonEmptyString(v, maxLen) {
|
|
151
|
+
if (typeof v !== 'string' || v.length === 0) return false;
|
|
152
|
+
if (typeof maxLen === 'number' && v.length > maxLen) return false;
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const _ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
|
|
157
|
+
|
|
158
|
+
function _isIsoDateTime(v) {
|
|
159
|
+
return typeof v === 'string' && _ISO_DATE_TIME_RE.test(v) && Number.isFinite(Date.parse(v));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The literal, verbatim `statePath` call this module's completeness-guard
|
|
164
|
+
* discipline depends on. Do not refactor this into a shared helper that
|
|
165
|
+
* takes the directory name as a parameter — `artifact-registry-
|
|
166
|
+
* completeness.test.js`'s `PATTERNS` needs a quoted literal at THIS call
|
|
167
|
+
* site to see it at all.
|
|
168
|
+
*/
|
|
169
|
+
export function observationsDir(scanRoot) {
|
|
170
|
+
return statePath(scanRoot, 'runtime-observations');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// B2 (final review, Part 2): a faithful LOCAL PORT of the established
|
|
174
|
+
// temp-file-then-rename shape (`_writeConfigAtomic` in
|
|
175
|
+
// `bin/agentic-security.js`, `_writeAtomicAndSync` in
|
|
176
|
+
// `posture/fix-history.js`) — NOT an import, since both those helpers are
|
|
177
|
+
// module-private and this module's own `persistObservationImport` is
|
|
178
|
+
// synchronous (every real caller, including the CLI and this file's own
|
|
179
|
+
// test suite, calls it without `await`), so the async `fsp`-based originals
|
|
180
|
+
// cannot be reused directly. Mirrors the remediation-ledger module's own
|
|
181
|
+
// documented precedent (posture/remediation-ledger, M5 deliverable #6) for
|
|
182
|
+
// porting rather than importing an unexported helper. Temp file in the
|
|
183
|
+
// SAME directory (so the final `renameSync` is
|
|
184
|
+
// same-filesystem and therefore atomic), a random suffix (so two
|
|
185
|
+
// concurrent writers can never collide on the temp file itself even before
|
|
186
|
+
// B2 Part 1's id-collision fix), fsync before rename when available, and
|
|
187
|
+
// the temp file is unlinked on any failure so a crash never leaves a stray
|
|
188
|
+
// partial file behind. Closes the "torn file on a genuine write race" risk
|
|
189
|
+
// even after Part 1 makes true id collisions impossible — belt and
|
|
190
|
+
// suspenders — and fixes `loadObservationImports`'s own silent-swallow-of-
|
|
191
|
+
// a-torn-file gap as a side effect (a write can no longer be torn at all).
|
|
192
|
+
function _writeAtomicSync(fp, content) {
|
|
193
|
+
const dir = path.dirname(fp);
|
|
194
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
195
|
+
const tmp = path.join(dir, `.${path.basename(fp)}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
|
|
196
|
+
try {
|
|
197
|
+
const fd = fs.openSync(tmp, 'w');
|
|
198
|
+
try {
|
|
199
|
+
fs.writeSync(fd, content);
|
|
200
|
+
fs.fsyncSync(fd);
|
|
201
|
+
} finally {
|
|
202
|
+
fs.closeSync(fd);
|
|
203
|
+
}
|
|
204
|
+
fs.renameSync(tmp, fp);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
try { fs.unlinkSync(tmp); } catch { /* never existed, or already gone — fine either way */ }
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The store's own file-name key from an import id, or `null` when
|
|
213
|
+
* `importId` is not a well-formed `obsimport:`-prefixed id. Validates the
|
|
214
|
+
* key's SHAPE before any `path.join` — see this file's own header.
|
|
215
|
+
*/
|
|
216
|
+
export function importFileName(importId) {
|
|
217
|
+
if (typeof importId !== 'string' || !/^obsimport:[0-9a-f]{12}$/.test(importId)) return null;
|
|
218
|
+
return `${importId.slice('obsimport:'.length)}.json`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Closed-world structural validation of an ObservationImport record.
|
|
223
|
+
* Mirrors `runtime-observation.js#validateRuntimeObservation`'s own
|
|
224
|
+
* `{valid, errors}`/`[{path, message}]` shape and closed-world discipline
|
|
225
|
+
* one level up: every element of `observations[]` is routed through
|
|
226
|
+
* `validateRuntimeObservation` itself, with its errors re-pathed to
|
|
227
|
+
* `$.observations[i].<path>`. Never throws.
|
|
228
|
+
*/
|
|
229
|
+
export function validateObservationImport(record) {
|
|
230
|
+
const errors = [];
|
|
231
|
+
const err = (p, message) => errors.push({ path: p, message });
|
|
232
|
+
|
|
233
|
+
if (!_isPlainObject(record)) {
|
|
234
|
+
err('$', 'ObservationImport record must be an object');
|
|
235
|
+
return { valid: false, errors };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Closed-world sweep FIRST, exactly like validateRuntimeObservation's
|
|
239
|
+
// own discipline — an unrecognized top-level key is always an error.
|
|
240
|
+
for (const key of Object.keys(record)) {
|
|
241
|
+
if (!IMPORT_FIELDS.includes(key)) {
|
|
242
|
+
err(`$.${key}`, 'unknown field — ObservationImport records are closed-world: only approved fields are accepted, and an unrecognized field is rejected, never ignored');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const field of IMPORT_FIELDS) {
|
|
247
|
+
if (!(field in record)) err(`$.${field}`, `${field} is required`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const idOk = 'id' in record && _isNonEmptyString(record.id) && record.id.startsWith('obsimport:');
|
|
251
|
+
if ('id' in record && !idOk) err('$.id', 'id is required and must start with "obsimport:"');
|
|
252
|
+
|
|
253
|
+
if ('version' in record && !_isNonEmptyString(record.version)) err('$.version', 'version is required');
|
|
254
|
+
|
|
255
|
+
if ('adapter' in record && !RUNTIME_OBSERVATION_ADAPTERS.includes(record.adapter)) {
|
|
256
|
+
err('$.adapter', `adapter must be one of ${RUNTIME_OBSERVATION_ADAPTERS.join('|')}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if ('source' in record && !_isNonEmptyString(record.source, 512)) {
|
|
260
|
+
err('$.source', 'source is required and must be at most 512 characters');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if ('environment' in record && !_isNonEmptyString(record.environment, 64)) {
|
|
264
|
+
err('$.environment', 'environment is required and must be at most 64 characters');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const windowStartOk = 'windowStart' in record && _isIsoDateTime(record.windowStart);
|
|
268
|
+
if ('windowStart' in record && !windowStartOk) err('$.windowStart', 'windowStart must be a parseable ISO-8601 date-time');
|
|
269
|
+
|
|
270
|
+
const windowEndOk = 'windowEnd' in record && _isIsoDateTime(record.windowEnd);
|
|
271
|
+
if ('windowEnd' in record && !windowEndOk) err('$.windowEnd', 'windowEnd must be a parseable ISO-8601 date-time');
|
|
272
|
+
|
|
273
|
+
if (windowStartOk && windowEndOk && Date.parse(record.windowStart) > Date.parse(record.windowEnd)) {
|
|
274
|
+
err('$.windowEnd', 'windowEnd must not be before windowStart');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if ('importedAt' in record && !_isIsoDateTime(record.importedAt)) {
|
|
278
|
+
err('$.importedAt', 'importedAt must be a parseable ISO-8601 date-time');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// retention: closed-key object with exactly `expiresAt`, mirroring
|
|
282
|
+
// runtime-observation.js's own field of the same name and shape.
|
|
283
|
+
if ('retention' in record) {
|
|
284
|
+
const retention = record.retention;
|
|
285
|
+
if (!_isPlainObject(retention)) {
|
|
286
|
+
err('$.retention', 'retention must be an object with exactly the key expiresAt');
|
|
287
|
+
} else {
|
|
288
|
+
for (const key of Object.keys(retention)) {
|
|
289
|
+
if (!_RETENTION_KEYS.includes(key)) {
|
|
290
|
+
err(`$.retention.${key}`, 'unknown field — retention is closed-world: only expiresAt is accepted');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const expiresAt = retention.expiresAt;
|
|
294
|
+
const expiresAtOk = expiresAt === null || _isIsoDateTime(expiresAt);
|
|
295
|
+
if (!expiresAtOk) {
|
|
296
|
+
err('$.retention.expiresAt', 'retention.expiresAt must be null or a parseable ISO-8601 date-time');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if ('observations' in record) {
|
|
302
|
+
if (!Array.isArray(record.observations)) {
|
|
303
|
+
err('$.observations', 'observations must be an array');
|
|
304
|
+
} else {
|
|
305
|
+
record.observations.forEach((obs, i) => {
|
|
306
|
+
const { errors: obsErrors } = validateRuntimeObservation(obs);
|
|
307
|
+
for (const e of obsErrors) {
|
|
308
|
+
errors.push({ path: `$.observations[${i}]${e.path.slice(1)}`, message: e.message });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { valid: errors.length === 0, errors };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Persist `importRecord` as one immutable whole file, keyed by its own
|
|
319
|
+
* `id`, written ATOMICALLY (B2) and SIGNED (I1, a sibling `<file>.sig`
|
|
320
|
+
* carrying `signLastScan` of the exact bytes written). Refuses (never
|
|
321
|
+
* partially writes) when the record fails validation, when state writes
|
|
322
|
+
* are disabled, when the target directory is not a safe state directory,
|
|
323
|
+
* when the id cannot be turned into a file name, or when the
|
|
324
|
+
* confidentiality gate (`maybeEncryptForWrite`) itself refuses. The store
|
|
325
|
+
* is the last line of defense: no path exists by which an unvalidated,
|
|
326
|
+
* unsigned, or torn observation reaches disk.
|
|
327
|
+
*
|
|
328
|
+
* @returns {{ok:true, path:string} | {ok:false, reason:string}}
|
|
329
|
+
*/
|
|
330
|
+
export function persistObservationImport(scanRoot, importRecord) {
|
|
331
|
+
const { valid, errors } = validateObservationImport(importRecord);
|
|
332
|
+
if (!valid) {
|
|
333
|
+
return { ok: false, reason: `invalid ObservationImport record: ${JSON.stringify(errors)}` };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!stateWritesEnabled()) {
|
|
337
|
+
return { ok: false, reason: 'state writes are disabled' };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const dir = observationsDir(scanRoot);
|
|
341
|
+
if (!isSafeStateDir(dir)) {
|
|
342
|
+
return { ok: false, reason: `refusing to write — ${dir} is not a safe state directory (no project marker found in its parent)` };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const fileName = importFileName(importRecord.id);
|
|
346
|
+
if (!fileName) {
|
|
347
|
+
return { ok: false, reason: `invalid import id "${importRecord.id}" — cannot derive a file name` };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const gated = maybeEncryptForWrite(scanRoot, 'runtime-observations', JSON.stringify(importRecord, null, 2));
|
|
351
|
+
if (!gated.ok) {
|
|
352
|
+
return gated;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const full = path.join(dir, fileName);
|
|
356
|
+
try {
|
|
357
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
358
|
+
// B2 Part 2: atomic write (temp-file-then-rename), replacing the prior
|
|
359
|
+
// bare writeFileSync.
|
|
360
|
+
_writeAtomicSync(full, gated.content);
|
|
361
|
+
// I1: sign the EXACT bytes just written (post-encryption, if any) —
|
|
362
|
+
// the same content a reader will read back and verify against, mirroring
|
|
363
|
+
// `lineage-graph.json`'s own sign-what-you-wrote discipline.
|
|
364
|
+
_writeAtomicSync(`${full}.sig`, signLastScan(gated.content));
|
|
365
|
+
} catch (e) {
|
|
366
|
+
return { ok: false, reason: `write failed: ${e.message}` };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { ok: true, path: full };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// I1: shared signature check for both readers below — a file nobody
|
|
373
|
+
// validated on write (planted by hand, or copied without its .sig sibling)
|
|
374
|
+
// must not become trusted by being on disk, at the SAME level of scrutiny
|
|
375
|
+
// a torn/malformed file already gets. `false` (tampered — the body doesn't
|
|
376
|
+
// match the .sig) and `null` (missing signature entirely — including every
|
|
377
|
+
// pre-existing unsigned import a hand-crafted forgery would produce) are
|
|
378
|
+
// both UNTRUSTED and treated identically: skip the record, never promote
|
|
379
|
+
// it to "valid" just because JSON.parse succeeded. Disclosed via a
|
|
380
|
+
// `console.error`, mirroring `recipient-registry.js#loadRecipientConfig`'s
|
|
381
|
+
// own established "tolerant degradation, never a silent drop with no
|
|
382
|
+
// trace" pattern — never an attribute key/value, only the file path and
|
|
383
|
+
// the verification outcome.
|
|
384
|
+
function _verifiedOrDisclose(full, raw) {
|
|
385
|
+
const verified = verifyLastScan(raw, `${full}.sig`);
|
|
386
|
+
if (verified === true) return true;
|
|
387
|
+
console.error(
|
|
388
|
+
verified === null
|
|
389
|
+
? `agentic-security: runtime observation import ${full} has no .sig file — refusing to trust an unsigned import (a hand-planted forgery would look identical). Skipped.`
|
|
390
|
+
: `agentic-security: runtime observation import ${full} FAILED signature verification — its contents do not match ${full}.sig. Refusing to trust a tampered import. Skipped.`,
|
|
391
|
+
);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* All persisted imports for scanRoot, newest first by mtime. Never
|
|
397
|
+
* throws — a missing/empty directory, a corrupt file, a non-`.json` file,
|
|
398
|
+
* an UNSIGNED or TAMPERED file (I1), or a file whose content fails
|
|
399
|
+
* `validateObservationImport` are all silently skipped, mirroring
|
|
400
|
+
* `graph-snapshot.js#loadSnapshots`'s own tolerance. A file nobody
|
|
401
|
+
* validated on write (planted by hand) must not become trusted by being on
|
|
402
|
+
* disk — hence the validate-on-read step, now preceded by the signature
|
|
403
|
+
* check.
|
|
404
|
+
*/
|
|
405
|
+
export function loadObservationImports(scanRoot) {
|
|
406
|
+
const dir = observationsDir(scanRoot);
|
|
407
|
+
if (!fs.existsSync(dir)) return [];
|
|
408
|
+
let files;
|
|
409
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
410
|
+
const withMtime = files.map((f) => {
|
|
411
|
+
const full = path.join(dir, f);
|
|
412
|
+
let mtimeMs = 0;
|
|
413
|
+
try { mtimeMs = fs.statSync(full).mtimeMs; } catch { /* keep 0 */ }
|
|
414
|
+
return { full, mtimeMs };
|
|
415
|
+
});
|
|
416
|
+
withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
417
|
+
const out = [];
|
|
418
|
+
for (const { full } of withMtime) {
|
|
419
|
+
try {
|
|
420
|
+
const onDisk = fs.readFileSync(full, 'utf8');
|
|
421
|
+
if (!_verifiedOrDisclose(full, onDisk)) continue;
|
|
422
|
+
const raw = maybeDecryptForRead(onDisk);
|
|
423
|
+
const parsed = JSON.parse(raw);
|
|
424
|
+
const { valid } = validateObservationImport(parsed);
|
|
425
|
+
if (valid) out.push(parsed);
|
|
426
|
+
} catch { /* skip corrupt/unreadable file, never throw */ }
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* One import by its id, or `null`. Validates the key's shape before any
|
|
433
|
+
* `path.join` — see this file's own header. Never throws.
|
|
434
|
+
*/
|
|
435
|
+
export function loadObservationImport(scanRoot, importId) {
|
|
436
|
+
const fileName = importFileName(importId);
|
|
437
|
+
if (!fileName) return null;
|
|
438
|
+
const full = path.join(observationsDir(scanRoot), fileName);
|
|
439
|
+
try {
|
|
440
|
+
const onDisk = fs.readFileSync(full, 'utf8');
|
|
441
|
+
if (!_verifiedOrDisclose(full, onDisk)) return null;
|
|
442
|
+
const raw = maybeDecryptForRead(onDisk);
|
|
443
|
+
const parsed = JSON.parse(raw);
|
|
444
|
+
const { valid } = validateObservationImport(parsed);
|
|
445
|
+
return valid ? parsed : null;
|
|
446
|
+
} catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Every observation across every persisted import, flattened and
|
|
453
|
+
* deduplicated by `id` (the newest import's copy wins, since
|
|
454
|
+
* `loadObservationImports` is newest-first), sorted by `id`. Never
|
|
455
|
+
* throws.
|
|
456
|
+
*/
|
|
457
|
+
export function loadObservations(scanRoot) {
|
|
458
|
+
const imports = loadObservationImports(scanRoot);
|
|
459
|
+
const map = new Map();
|
|
460
|
+
for (const imp of imports) {
|
|
461
|
+
for (const obs of imp.observations ?? []) {
|
|
462
|
+
if (obs && typeof obs.id === 'string' && !map.has(obs.id)) {
|
|
463
|
+
map.set(obs.id, obs);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Delete one import file by id. Validates the key's shape before any
|
|
472
|
+
* `path.join` — a malformed/traversal-shaped id refuses and unlinks
|
|
473
|
+
* nothing. Returns `true` on a real deletion, `false` otherwise
|
|
474
|
+
* (malformed id, file already gone, or an unlink error) — never throws.
|
|
475
|
+
*
|
|
476
|
+
* This is the property an append-only hash chain (the remediation ledger's
|
|
477
|
+
* own shape) could not provide: FR-505 requires an observation store
|
|
478
|
+
* follow real artifact encryption, RETENTION, RESET, access-control, and
|
|
479
|
+
* no-egress rules, and a hash chain makes deletion structurally
|
|
480
|
+
* impossible.
|
|
481
|
+
*/
|
|
482
|
+
export function deleteObservationImport(scanRoot, importId) {
|
|
483
|
+
const fileName = importFileName(importId);
|
|
484
|
+
if (!fileName) return false;
|
|
485
|
+
const full = path.join(observationsDir(scanRoot), fileName);
|
|
486
|
+
try {
|
|
487
|
+
fs.unlinkSync(full);
|
|
488
|
+
// I1: best-effort cleanup of the sibling .sig — never load-bearing for
|
|
489
|
+
// this function's own true/false return (the main file's own unlink is
|
|
490
|
+
// what determines success/failure), just hygiene so a deleted import
|
|
491
|
+
// doesn't leave an orphaned signature file behind.
|
|
492
|
+
try { fs.unlinkSync(`${full}.sig`); } catch { /* absent or already gone — fine either way */ }
|
|
493
|
+
return true;
|
|
494
|
+
} catch {
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|