@clear-capabilities/agentic-security-scanner 0.154.0 → 0.155.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 +13 -0
- package/bin/agentic-security.js +8 -0
- package/dist/1919.index.js +445 -0
- package/dist/2862.index.js +2 -2
- package/dist/8551.index.js +47 -19
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +6 -4
- package/src/engine.js +2 -2
- package/src/language/assurance.js +2 -2
- package/src/language/haskell-supply.js +6 -6
- package/src/language/resolved-pass.js +45 -17
- package/src/language/support-registry.js +66 -1
- package/src/language/trusted-inputs.js +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,19 @@
|
|
|
9
9
|
> make the history less accurate, not more.
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
## 0.155.0 - premortem remediation (trust boundary, unseen-shape measurement), NixOS VM check
|
|
13
|
+
|
|
14
|
+
An adversarial premortem of 0.154.0 found the following; each is fixed here and covered by `test/language/language-trust-boundary.test.js`.
|
|
15
|
+
|
|
16
|
+
- **Trust boundary.** Advisory snapshots are read only from the operator (environment variable or `$XDG_CONFIG_HOME/agentic-security/`). A snapshot inside the scanned project is ignored and the scan says so, so a repository can no longer ship an empty feed to look clean. Nix export freshness is derived by the scanner from `flake.lock`, project-supplied exports and plans are disclosed, a refused export contributes nothing, and a Cabal plan that contradicts the freeze file is flagged.
|
|
17
|
+
- **Unseen-shape split.** The holdout shares its code shapes with development, so it measures robustness to renaming only. A new `unseen` split (300 author-labelled cases, 3 new vulnerable and 3 new safe shapes per family) is measured once and never tuned against. Its result is stored beside every holdout row with 95% Clopper-Pearson intervals and strict precision. It is **below target** for Haskell SAST (R 79.2%), Haskell taint (P 77.8%) and Nix config-sast (P 86.1%); those are engine gaps, not fixed here. The `auth` row is now promoted from its own holdout evidence.
|
|
18
|
+
- **Completion honesty.** A release check (`completion-declared`) fails when `docs/completion-status.json` lists unverified requirements and the changelog has no paragraph naming them. Chrome-dependent export tests are a separate informational job rather than a silent skip.
|
|
19
|
+
- **Nix package fix (found by the first hosted NixOS guest run).** The packaged `agentic-security-mcp` and `agentic-security-lsp` wrappers ran `bin/*.js`, which import `src/` and therefore npm dependencies the Nix package does not ship, so both would have crashed for a Nix user. They now run from the self-contained bundle, and the CLI gained an `lsp` subcommand (the same server the editor plugins start). The flake's package source is now exactly the files it installs, so an offline rebuild elsewhere reproduces the same derivation.
|
|
20
|
+
- **Destructive-command guard.** `hooks/pre-bash-guard.js` judged `rm` by text, so it refused any absolute path (and `docker run --rm -v /abs/...`) while missing nothing it should. Deletion (`rm`, `rmdir`, `unlink`, `find -delete`) is now path-aware: targets are parsed and resolved (`~`, `$HOME`, relative paths, `cd`, symlinks) and allowed only strictly inside an allowed root (default `~/code`, set with `allowedRoots` in `.agentic-security/destructive-guard.json`). Anything outside, anything unresolvable (`$VAR`, command substitution, `xargs`) and the root itself is refused. It cannot see deletion done inside another program, so it guards against mistakes and is not a sandbox. Pinned by `test/pre-bash-guard.test.js`.
|
|
21
|
+
- **Not done.** Peak scan memory is measured under a stated heap cap and was **not reduced**: a lazy-token experiment retained less but changed no measurement, so it was reverted. `flake.nix` is now locked (`flake.lock`), built and checked on aarch64-linux in a `nixos/nix` container (Nix 2.35.2) and on x86_64-linux on a hosted runner; aarch64-darwin is declared but unbuilt, and x86_64-darwin is dropped (current nixpkgs no longer supports it).
|
|
22
|
+
|
|
23
|
+
Partial release: the loop controller lists NIX-011, NIX-012 and REL-001 as not verified. NIX-011 and NIX-012 passed on hosted CI in this release's pull request (the NIX-011 suite on Nix 2.24.10; the NIX-012 suite inside a real NixOS guest booted by the flake's `nixos-host` check on an x86_64 runner with KVM), but that is CI evidence, not controller-signed evidence, because the controller runs where `nix` and `/etc/NIXOS` are absent. The aarch64 NixOS guest was not run: the arm runner has no KVM and a software-emulated guest does not boot in any usable time. REL-001 is the controller's own final acceptance and needs those two to be controller-verified first.
|
|
24
|
+
|
|
12
25
|
## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
|
|
13
26
|
|
|
14
27
|
Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
|
package/bin/agentic-security.js
CHANGED
|
@@ -137,6 +137,7 @@ Commands:
|
|
|
137
137
|
tickets sync --provider <p> Two-way sync findings ↔ GitHub Issues / Linear / Jira
|
|
138
138
|
digest --slack <webhook> Vibecoder: send daily digest to Slack
|
|
139
139
|
mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
|
|
140
|
+
lsp Start the Language Server Protocol server on stdio (what the editor plugins run)
|
|
140
141
|
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
141
142
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
142
143
|
ask "<question>" [target] Bounded local-model Q&A with read-only tool access (requires AGENTIC_SECURITY_LLM_PRESET=ollama)
|
|
@@ -7056,6 +7057,13 @@ async function main() {
|
|
|
7056
7057
|
runStdio({ sessionRoot: path.resolve(root) });
|
|
7057
7058
|
return;
|
|
7058
7059
|
}
|
|
7060
|
+
case 'lsp': {
|
|
7061
|
+
// The same server as bin/agentic-security-lsp.js, reachable from the self-contained bundle: a package that ships only the bundle
|
|
7062
|
+
// (the Nix package does) has no node_modules for the src/ entry point to import from.
|
|
7063
|
+
const { startLspServer } = await import('../src/lsp/server.js');
|
|
7064
|
+
startLspServer();
|
|
7065
|
+
return;
|
|
7066
|
+
}
|
|
7059
7067
|
case 'explore': process.exit(await cmdExplore(args));
|
|
7060
7068
|
case 'dataflow': {
|
|
7061
7069
|
const sub = args._[1];
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
export const id = 1919;
|
|
2
|
+
export const ids = [1919];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 21919:
|
|
6
|
+
/***/ ((__webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
__webpack_require__.a(__webpack_module__, async (__webpack_handle_async_dependencies__, __webpack_async_result__) => { try {
|
|
9
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
10
|
+
/* harmony export */ startLspServer: () => (/* binding */ startLspServer)
|
|
11
|
+
/* harmony export */ });
|
|
12
|
+
/* unused harmony export _internals */
|
|
13
|
+
/* harmony import */ var _language_discovery_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(30951);
|
|
14
|
+
/* harmony import */ var _language_context_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(15911);
|
|
15
|
+
/* harmony import */ var node_fs__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(73024);
|
|
16
|
+
/* harmony import */ var node_path__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(76760);
|
|
17
|
+
/* harmony import */ var node_readline__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(80481);
|
|
18
|
+
/* harmony import */ var _runScan_js__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(45950);
|
|
19
|
+
/* harmony import */ var _posture_custom_rules_js__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(9049);
|
|
20
|
+
/* harmony import */ var _posture_state_dir_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(31174);
|
|
21
|
+
/* harmony import */ var _mcp_redact_js__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(83468);
|
|
22
|
+
/* harmony import */ var _report_index_js__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(75917);
|
|
23
|
+
var __webpack_async_dependencies__ = __webpack_handle_async_dependencies__([_runScan_js__WEBPACK_IMPORTED_MODULE_5__, _report_index_js__WEBPACK_IMPORTED_MODULE_8__]);
|
|
24
|
+
([_runScan_js__WEBPACK_IMPORTED_MODULE_5__, _report_index_js__WEBPACK_IMPORTED_MODULE_8__] = __webpack_async_dependencies__.then ? (await __webpack_async_dependencies__)() : __webpack_async_dependencies__);
|
|
25
|
+
// Minimal LSP server for agentic-security.
|
|
26
|
+
//
|
|
27
|
+
// Speaks the Language Server Protocol over stdio. On every textDocument/
|
|
28
|
+
// didSave (and didOpen), the server runs runScan on the file and emits
|
|
29
|
+
// textDocument/publishDiagnostics with the resulting findings mapped to
|
|
30
|
+
// LSP Diagnostic objects.
|
|
31
|
+
//
|
|
32
|
+
// This is a STARTER implementation — feature-complete enough that JetBrains
|
|
33
|
+
// (via LSP4IJ) and Neovim (via built-in LSP) can both attach and see
|
|
34
|
+
// findings inline. The full feature set (code actions for /fix, inline
|
|
35
|
+
// remediation hover, exploitability tooltip) is future work.
|
|
36
|
+
//
|
|
37
|
+
// Wire-format: vscode-jsonrpc framing (Content-Length headers). Stateless
|
|
38
|
+
// per file — no incremental analysis yet.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
const PROTOCOL_VERSION = '3.17';
|
|
52
|
+
const SERVER_NAME = 'agentic-security-lsp';
|
|
53
|
+
const SERVER_VERSION = '0.1.0';
|
|
54
|
+
|
|
55
|
+
let _rootUri = null;
|
|
56
|
+
let _rootDir = process.cwd();
|
|
57
|
+
let _stdoutMutex = Promise.resolve();
|
|
58
|
+
const _diagnosticsByUri = new Map();
|
|
59
|
+
|
|
60
|
+
function uriToPath(uri) {
|
|
61
|
+
if (!uri) return null;
|
|
62
|
+
if (uri.startsWith('file://')) return decodeURIComponent(uri.slice(7));
|
|
63
|
+
return uri;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pathToUri(p) {
|
|
67
|
+
if (!p) return null;
|
|
68
|
+
if (p.startsWith('file://')) return p;
|
|
69
|
+
return 'file://' + encodeURI(node_path__WEBPACK_IMPORTED_MODULE_3__.resolve(p));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sevToLsp(sev) {
|
|
73
|
+
switch ((sev || '').toLowerCase()) {
|
|
74
|
+
case 'critical': return 1; // Error
|
|
75
|
+
case 'high': return 1; // Error
|
|
76
|
+
case 'medium': return 2; // Warning
|
|
77
|
+
case 'low': return 3; // Information
|
|
78
|
+
default: return 4; // Hint
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The most precise location a finding carries. Haskell and Nix findings keep their ORIGINAL span (0-based columns); a
|
|
83
|
+
// finding with only a line is reported across that line, as before.
|
|
84
|
+
function _rangeOf(f) {
|
|
85
|
+
const o = f && f.originalLocation;
|
|
86
|
+
if (o && Number.isInteger(o.startLine) && Number.isInteger(o.startColumn)) {
|
|
87
|
+
return { start: { line: Math.max(0, o.startLine - 1), character: o.startColumn }, end: { line: Math.max(0, (Number.isInteger(o.endLine) ? o.endLine : o.startLine) - 1), character: Number.isInteger(o.endColumn) ? o.endColumn : o.startColumn + 1 } };
|
|
88
|
+
}
|
|
89
|
+
if (o && Number.isInteger(o.line) && Number.isInteger(o.column)) {
|
|
90
|
+
const line = Math.max(0, o.line - 1);
|
|
91
|
+
return { start: { line, character: o.column }, end: { line, character: 200 } };
|
|
92
|
+
}
|
|
93
|
+
const line = Math.max(0, ((f && f.line) || 1) - 1);
|
|
94
|
+
return { start: { line, character: 0 }, end: { line, character: 200 } };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function findingToDiagnostic(f) {
|
|
98
|
+
const line = Math.max(0, (f.line || 1) - 1);
|
|
99
|
+
// Stage 6 correctness audit: this read f.remediation directly, but raw
|
|
100
|
+
// scan.findings entries (what this consumes, pre-normalizeFindings) come
|
|
101
|
+
// from two conventions — most posture/*.js and newer sast/*.js modules
|
|
102
|
+
// set `remediation`, while ~127 of engine.js's own detectors set a `fix`
|
|
103
|
+
// STRING field instead. _remediationOf carries the same precedence
|
|
104
|
+
// report/index.js already established for this exact split (CMP-3).
|
|
105
|
+
const remediation = (0,_report_index_js__WEBPACK_IMPORTED_MODULE_8__/* ._remediationOf */ .uV)(f);
|
|
106
|
+
return {
|
|
107
|
+
range: _rangeOf(f),
|
|
108
|
+
severity: sevToLsp(f.severity),
|
|
109
|
+
source: 'agentic-security',
|
|
110
|
+
code: f.cwe || f.family || 'finding',
|
|
111
|
+
// carried back by the client in codeAction requests, so a fix is offered for exactly this finding
|
|
112
|
+
data: { id: f.id || null, stableId: f.stableId || null, rule: f.rule || null, family: f.family || null, language: f.language || (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageOfFinding)(f) || null },
|
|
113
|
+
message: `${f.vuln || 'Security finding'}${remediation ? '\n\n' + remediation : ''}`.slice(0, 2000),
|
|
114
|
+
tags: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function send(message) {
|
|
119
|
+
const json = JSON.stringify({ jsonrpc: '2.0', ...message });
|
|
120
|
+
_stdoutMutex = _stdoutMutex.then(() => new Promise(resolve => {
|
|
121
|
+
process.stdout.write(`Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n${json}`, resolve);
|
|
122
|
+
}));
|
|
123
|
+
return _stdoutMutex;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function publishDiagnostics(uri, findings) {
|
|
127
|
+
await send({
|
|
128
|
+
method: 'textDocument/publishDiagnostics',
|
|
129
|
+
params: { uri, diagnostics: findings.map(findingToDiagnostic) },
|
|
130
|
+
});
|
|
131
|
+
_diagnosticsByUri.set(uri, findings);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Manifest / schema files that downstream passes (SCA, cross-language) read.
|
|
135
|
+
// We walk the project tree once per LSP session and cache these so the
|
|
136
|
+
// per-save scan has them.
|
|
137
|
+
const DEP_BASE_NAMES = new Set([
|
|
138
|
+
'package.json', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml',
|
|
139
|
+
'requirements.txt', 'pyproject.toml', 'poetry.lock', 'Pipfile.lock',
|
|
140
|
+
'composer.json', 'composer.lock', 'Gemfile', 'Gemfile.lock',
|
|
141
|
+
'go.mod', 'Cargo.toml', 'Cargo.lock',
|
|
142
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts',
|
|
143
|
+
]);
|
|
144
|
+
const DEP_EXT_RE = /\.(?:proto|graphql|gql|tf|cabal)$/i;
|
|
145
|
+
// Haskell and Nix project manifests and lock files (cabal.project*, stack.yaml*, package.yaml, flake.lock, ...) are
|
|
146
|
+
// recognised by the shared language discovery module, never by a second list here.
|
|
147
|
+
const _isLanguageDep = (base) => (0,_language_discovery_js__WEBPACK_IMPORTED_MODULE_0__/* .isLanguageManifest */ .mD)(base);
|
|
148
|
+
const DEP_NAME_RE = /(?:openapi|swagger)\.(?:ya?ml|json)$/i;
|
|
149
|
+
|
|
150
|
+
let _depCache = { rootDir: null, depFileContents: {} };
|
|
151
|
+
|
|
152
|
+
function _loadDepFileContents(rootDir) {
|
|
153
|
+
if (_depCache.rootDir === rootDir) return _depCache.depFileContents;
|
|
154
|
+
const out = {};
|
|
155
|
+
const skipDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'target', 'vendor', '.bench-cache']);
|
|
156
|
+
function walk(dir) {
|
|
157
|
+
let entries;
|
|
158
|
+
try { entries = node_fs__WEBPACK_IMPORTED_MODULE_2__.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
if (skipDirs.has(e.name)) continue;
|
|
161
|
+
const full = node_path__WEBPACK_IMPORTED_MODULE_3__.join(dir, e.name);
|
|
162
|
+
if (e.isDirectory()) { walk(full); continue; }
|
|
163
|
+
if (!e.isFile()) continue;
|
|
164
|
+
const base = e.name;
|
|
165
|
+
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
|
|
166
|
+
let stat;
|
|
167
|
+
try { stat = node_fs__WEBPACK_IMPORTED_MODULE_2__.statSync(full); } catch { continue; }
|
|
168
|
+
if (stat.size > 500_000) continue;
|
|
169
|
+
try { out[node_path__WEBPACK_IMPORTED_MODULE_3__.relative(rootDir, full)] = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(full, 'utf8'); }
|
|
170
|
+
catch { /* skip unreadable */ }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
walk(rootDir);
|
|
175
|
+
_depCache = { rootDir, depFileContents: out };
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function scanFile(uri) {
|
|
180
|
+
const filePath = uriToPath(uri);
|
|
181
|
+
if (!filePath || !node_fs__WEBPACK_IMPORTED_MODULE_2__.existsSync(filePath)) return;
|
|
182
|
+
// Incremental scan (premortem 2R4.5 / 2R-10): hand runScan a single-file
|
|
183
|
+
// fileContents map for the saved code, AND a cached set of dep-manifest /
|
|
184
|
+
// schema files so SCA + cross-language passes have their inputs. Without
|
|
185
|
+
// depFileContents, the LSP path would silently drop CVE / OpenAPI / proto
|
|
186
|
+
// findings on the saved file.
|
|
187
|
+
try {
|
|
188
|
+
const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, filePath);
|
|
189
|
+
const content = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(filePath, 'utf8');
|
|
190
|
+
let fileContents = { [rel]: content };
|
|
191
|
+
let depFileContents = _loadDepFileContents(_rootDir);
|
|
192
|
+
// Haskell/Nix: scan the saved file together with the modules it imports and the ones that import it (bounded).
|
|
193
|
+
const lc = (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.withLanguageContext)(_rootDir, fileContents, depFileContents);
|
|
194
|
+
fileContents = lc.fileContents; depFileContents = lc.depFileContents;
|
|
195
|
+
// Premortem 4R-12 + 4R-15: reset the per-process custom-rules budget at
|
|
196
|
+
// the start of each LSP scan. Each save is a logical scan session; without
|
|
197
|
+
// the reset, a long-lived LSP server would accumulate budget across saves
|
|
198
|
+
// and eventually start skipping custom rules.
|
|
199
|
+
(0,_posture_custom_rules_js__WEBPACK_IMPORTED_MODULE_6__/* .resetCustomRulesBudget */ .zO)(_rootDir);
|
|
200
|
+
// PRD R1 (docs/DETECTION_GAP_REMEDIATION_PRD.md): deep mode is
|
|
201
|
+
// default-on for the interactive CLI scan but was never requested here,
|
|
202
|
+
// so every on-save diagnostic pass was regex/AST-only — blind to any bug
|
|
203
|
+
// whose source and sink are connected only through a call. Scoped to
|
|
204
|
+
// exactly the saved file (fileContents has one entry), so this does not
|
|
205
|
+
// turn every keystroke's save into a full-project deep scan.
|
|
206
|
+
// withStateWritesDisabled, for the same reason mcp/tools.js's scan_diff
|
|
207
|
+
// wraps its own partial-set scan (FR-704). This is a DIAGNOSTIC surface: it
|
|
208
|
+
// runs on every file save, against the user's real project root, with a
|
|
209
|
+
// fileContents map holding exactly one file. Without the wrapper,
|
|
210
|
+
// runFullScan's state writers fire on every keystroke-save — dpia.md,
|
|
211
|
+
// ropa.md, privacy-framework.json, threat-model.json and the rest, written
|
|
212
|
+
// into the user's tree by an editor plugin they never asked to mutate
|
|
213
|
+
// anything.
|
|
214
|
+
//
|
|
215
|
+
// The provenance lifecycle store makes that actively destructive rather
|
|
216
|
+
// than merely noisy: updateLifecycle marks every open stableId ABSENT from
|
|
217
|
+
// the finding set it is handed as `remediated`, and this set is one file's
|
|
218
|
+
// worth of findings. Every save would remediate the whole project, and the
|
|
219
|
+
// next real scan would reintroduce it.
|
|
220
|
+
//
|
|
221
|
+
// Chosen over forwarding `provenance:false` through runScan because that
|
|
222
|
+
// would fix only the lifecycle half and leave the other state writers
|
|
223
|
+
// firing. The flag is process-global (see its KNOWN LIMITATION), which is
|
|
224
|
+
// harmless here: this server is a read-only surface whose every scan wants
|
|
225
|
+
// writes off, so overlapping saves can only ever agree, and the `finally`
|
|
226
|
+
// restores the prior value either way. exceptCategories:['provenance-cache']
|
|
227
|
+
// (M2 §2.4) is the one deliberate exception — every OTHER write this scan
|
|
228
|
+
// would make stays suppressed, but the provenance disk cache stays live so
|
|
229
|
+
// repeated saves of the same file are not each paying the full uncached
|
|
230
|
+
// resolution cost.
|
|
231
|
+
const { scan } = await (0,_posture_state_dir_js__WEBPACK_IMPORTED_MODULE_7__/* .withStateWritesDisabled */ .Ao)(() =>
|
|
232
|
+
(0,_runScan_js__WEBPACK_IMPORTED_MODULE_5__.runScan)(_rootDir, { fileContents, depFileContents, deep: true, deepInCi: true }),
|
|
233
|
+
{ exceptCategories: ['provenance-cache'] });
|
|
234
|
+
// Stage 6 correctness audit: this only ever read scan.findings (the SAST
|
|
235
|
+
// channel). scan.secrets and scan.logicVulns are separate arrays on the
|
|
236
|
+
// raw runScan() result — normalizeFindings is what merges all four
|
|
237
|
+
// channels, and that hasn't run here — so a saved file with a hardcoded
|
|
238
|
+
// credential got a clean problem pane, no diagnostic at all. Unlike the
|
|
239
|
+
// MCP surface, this server never applied redactFinding either (nothing
|
|
240
|
+
// here imported mcp/redact.js), which would have been a landmine the
|
|
241
|
+
// moment secrets/logicVulns were added without it: those channels are
|
|
242
|
+
// exactly where raw secret material shows up in `snippet`. Both fixed
|
|
243
|
+
// together — merge the channels AND redact — so the fix for one gap
|
|
244
|
+
// doesn't open the other.
|
|
245
|
+
const findings = [...(scan.findings || []), ...(scan.secrets || []), ...(scan.logicVulns || [])]
|
|
246
|
+
.filter(f => f.file === rel)
|
|
247
|
+
.map(f => (0,_mcp_redact_js__WEBPACK_IMPORTED_MODULE_9__/* .redactFinding */ .lE)(f));
|
|
248
|
+
await publishDiagnostics(uri, findings);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
process.stderr.write(`agentic-security-lsp: scan failed: ${e.message}\n`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleInitialize(params) {
|
|
255
|
+
if (params.rootUri) {
|
|
256
|
+
_rootUri = params.rootUri;
|
|
257
|
+
_rootDir = uriToPath(params.rootUri) || process.cwd();
|
|
258
|
+
} else if (params.rootPath) {
|
|
259
|
+
_rootDir = params.rootPath;
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
capabilities: {
|
|
263
|
+
textDocumentSync: {
|
|
264
|
+
openClose: true,
|
|
265
|
+
change: 1,
|
|
266
|
+
save: { includeText: false },
|
|
267
|
+
},
|
|
268
|
+
diagnosticProvider: { interFileDependencies: true, workspaceDiagnostics: false },
|
|
269
|
+
// quick fixes for Haskell and Nix findings (preview edits from the verified language fixers)
|
|
270
|
+
codeActionProvider: { codeActionKinds: ['quickfix'], resolveProvider: false },
|
|
271
|
+
},
|
|
272
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* textDocument/codeAction: a quick fix for each agentic-security diagnostic in range that a language fixer can repair.
|
|
278
|
+
* The edit is a PREVIEW the editor applies; nothing is written here. A finding with no safe fix gets a disabled
|
|
279
|
+
* action that says why, never a silent omission.
|
|
280
|
+
*/
|
|
281
|
+
async function codeActionsFor(params) {
|
|
282
|
+
const uri = params && params.textDocument && params.textDocument.uri;
|
|
283
|
+
const diags = ((params && params.context && params.context.diagnostics) || []).filter((d) => d && d.source === 'agentic-security' && d.data && d.data.id);
|
|
284
|
+
const stored = (uri && _diagnosticsByUri.get(uri)) || [];
|
|
285
|
+
if (!diags.length || !stored.length) return [];
|
|
286
|
+
let files = null;
|
|
287
|
+
const actions = [];
|
|
288
|
+
for (const d of diags) {
|
|
289
|
+
const f = stored.find((x) => x && x.id === d.data.id);
|
|
290
|
+
if (!f || !(0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageOfFinding)(f)) continue;
|
|
291
|
+
if (!files) files = (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.loadLanguageProject)(_rootDir).files;
|
|
292
|
+
let prev;
|
|
293
|
+
try { prev = await (0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.languageFixPreview)(f, files); } catch (e) { prev = { ok: false, reason: String((e && e.message) || e) }; }
|
|
294
|
+
if (prev && prev.ok && prev.file) {
|
|
295
|
+
const target = pathToUri(node_path__WEBPACK_IMPORTED_MODULE_3__.join(_rootDir, prev.file));
|
|
296
|
+
actions.push({
|
|
297
|
+
title: `Fix${prev.label ? ` (${prev.label})` : ''}: ${f.vuln || f.rule || 'finding'}`,
|
|
298
|
+
kind: 'quickfix', diagnostics: [d], isPreferred: false,
|
|
299
|
+
edit: { changes: { [target]: [(0,_language_context_js__WEBPACK_IMPORTED_MODULE_1__.minimalEdit)(prev.before, prev.after)] } },
|
|
300
|
+
data: { stableId: f.stableId || null, verified: { syntax: true, rescan: true } },
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
actions.push({ title: `No automatic fix: ${f.vuln || f.rule || 'finding'}`, kind: 'quickfix', diagnostics: [d], disabled: { reason: (prev && (prev.reason || prev.status)) || 'no deterministic fix exists for this finding' } });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return actions;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function handleMessage(msg) {
|
|
310
|
+
if (msg.method === 'textDocument/codeAction') {
|
|
311
|
+
return { id: msg.id, result: await codeActionsFor(msg.params || {}) };
|
|
312
|
+
}
|
|
313
|
+
if (msg.method === 'initialize') {
|
|
314
|
+
return { id: msg.id, result: handleInitialize(msg.params || {}) };
|
|
315
|
+
}
|
|
316
|
+
if (msg.method === 'initialized' || msg.method === 'workspace/didChangeConfiguration') {
|
|
317
|
+
return null; // notification, no response
|
|
318
|
+
}
|
|
319
|
+
if (msg.method === 'shutdown') {
|
|
320
|
+
return { id: msg.id, result: null };
|
|
321
|
+
}
|
|
322
|
+
if (msg.method === 'exit') {
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
325
|
+
if (msg.method === 'textDocument/didOpen') {
|
|
326
|
+
const uri = msg.params?.textDocument?.uri;
|
|
327
|
+
if (uri) scanFile(uri);
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
if (msg.method === 'textDocument/didSave') {
|
|
331
|
+
const uri = msg.params?.textDocument?.uri;
|
|
332
|
+
if (uri) {
|
|
333
|
+
// Premortem 3R-9 / 4R-5: when the user saves a manifest file, the
|
|
334
|
+
// dep-cache entry for THAT file is stale. Granular invalidation (only
|
|
335
|
+
// re-read the saved file from disk) avoids the O(project) re-walk that
|
|
336
|
+
// 3R-9 introduced — important in monorepos where mass manifest edits
|
|
337
|
+
// would otherwise re-scan thousands of files per save.
|
|
338
|
+
const savedPath = uriToPath(uri);
|
|
339
|
+
if (savedPath && _depCache.rootDir === _rootDir) {
|
|
340
|
+
const base = node_path__WEBPACK_IMPORTED_MODULE_3__.basename(savedPath);
|
|
341
|
+
if (DEP_BASE_NAMES.has(base) || DEP_EXT_RE.test(base) || DEP_NAME_RE.test(base) || _isLanguageDep(base)) {
|
|
342
|
+
try {
|
|
343
|
+
const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, savedPath);
|
|
344
|
+
const st = node_fs__WEBPACK_IMPORTED_MODULE_2__.statSync(savedPath);
|
|
345
|
+
if (st.size <= 500_000) {
|
|
346
|
+
_depCache.depFileContents[rel] = node_fs__WEBPACK_IMPORTED_MODULE_2__.readFileSync(savedPath, 'utf8');
|
|
347
|
+
} else {
|
|
348
|
+
delete _depCache.depFileContents[rel];
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// File vanished between save event and stat — drop from cache.
|
|
352
|
+
try {
|
|
353
|
+
const rel = node_path__WEBPACK_IMPORTED_MODULE_3__.relative(_rootDir, savedPath);
|
|
354
|
+
delete _depCache.depFileContents[rel];
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
scanFile(uri);
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
if (msg.method === 'textDocument/didClose') {
|
|
364
|
+
const uri = msg.params?.textDocument?.uri;
|
|
365
|
+
if (uri) await publishDiagnostics(uri, []);
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
// Unknown method.
|
|
369
|
+
if (msg.id != null) {
|
|
370
|
+
return { id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function startLspServer() {
|
|
376
|
+
let buffer = Buffer.alloc(0);
|
|
377
|
+
let expected = -1;
|
|
378
|
+
process.stdin.on('data', async (chunk) => {
|
|
379
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
380
|
+
while (true) {
|
|
381
|
+
if (expected < 0) {
|
|
382
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
383
|
+
if (headerEnd < 0) break;
|
|
384
|
+
const headers = buffer.slice(0, headerEnd).toString('utf8');
|
|
385
|
+
const m = headers.match(/Content-Length:\s*(\d+)/i);
|
|
386
|
+
if (!m) {
|
|
387
|
+
process.stderr.write('agentic-security-lsp: missing Content-Length header\n');
|
|
388
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
expected = parseInt(m[1], 10);
|
|
392
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
393
|
+
}
|
|
394
|
+
if (buffer.length < expected) break;
|
|
395
|
+
const body = buffer.slice(0, expected).toString('utf8');
|
|
396
|
+
buffer = buffer.slice(expected);
|
|
397
|
+
expected = -1;
|
|
398
|
+
let msg;
|
|
399
|
+
try { msg = JSON.parse(body); }
|
|
400
|
+
catch { process.stderr.write('agentic-security-lsp: malformed JSON\n'); continue; }
|
|
401
|
+
try {
|
|
402
|
+
const response = await handleMessage(msg);
|
|
403
|
+
if (response) await send(response);
|
|
404
|
+
} catch (e) {
|
|
405
|
+
if (msg.id != null) await send({ id: msg.id, error: { code: -32603, message: e.message } });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
process.stdin.on('end', () => process.exit(0));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Allow direct invocation as a bin entry: `node lsp/server.js`.
|
|
413
|
+
//
|
|
414
|
+
// `import.meta.url === file://${process.argv[1]}` looks equivalent but is
|
|
415
|
+
// NOT: when this script is invoked through a symlink (exactly what
|
|
416
|
+
// `npm install -g`, `npx`, and `node_modules/.bin/<name>` all do for a
|
|
417
|
+
// package's `bin` entries — and `agentic-security-lsp` IS one of this
|
|
418
|
+
// package's bin entries), Node resolves `import.meta.url` to the symlink's
|
|
419
|
+
// realpath while `process.argv[1]` stays the symlink path as invoked, so the
|
|
420
|
+
// two never match, the guard is always false, and the server silently exits
|
|
421
|
+
// with no output — an editor would see the language server start and
|
|
422
|
+
// immediately die with nothing on stderr to explain it. `import.meta.main` is
|
|
423
|
+
// resolved correctly through a symlink. It was added in Node v24.2.0
|
|
424
|
+
// (backported to v22.18.0) and is currently Stability 1.0 (early development)
|
|
425
|
+
// per Node's own docs — NOT stable, and NOT available on v20.11. Concretely:
|
|
426
|
+
// it is `undefined` on Node 24.0.0/24.1.x, which satisfy this repo's declared
|
|
427
|
+
// `engines.node: ">=24.0.0"` floor, so `import.meta.main` alone would
|
|
428
|
+
// reproduce this exact bug on a plain non-symlinked invocation under those two
|
|
429
|
+
// point releases. The `??` fallback covers that gap without bumping the
|
|
430
|
+
// engines floor. Identical to bin/agentic-security.js's guard, deliberately —
|
|
431
|
+
// see the long-form note there.
|
|
432
|
+
if (import.meta.main ?? (import.meta.url === `file://${process.argv[1]}`)) {
|
|
433
|
+
startLspServer();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function _setRootDir(dir) { _rootDir = dir; _depCache = { rootDir: null, depFileContents: {} }; }
|
|
437
|
+
|
|
438
|
+
const _internals = { findingToDiagnostic, scanFile, uriToPath, pathToUri, _diagnosticsByUri, _setRootDir };
|
|
439
|
+
|
|
440
|
+
__webpack_async_result__();
|
|
441
|
+
} catch(e) { __webpack_async_result__(e); } });
|
|
442
|
+
|
|
443
|
+
/***/ })
|
|
444
|
+
|
|
445
|
+
};
|
package/dist/2862.index.js
CHANGED
|
@@ -261,14 +261,14 @@ async function assessLanguageAssurance(input = {}) {
|
|
|
261
261
|
const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
|
|
262
262
|
if (present.has('haskell') && scaRequired && manifests.length) {
|
|
263
263
|
const db = input.advisoryDb;
|
|
264
|
-
if (!db) conditions.push(
|
|
264
|
+
if (!db) conditions.push(`no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed${input.advisoryReason ? `. ${input.advisoryReason}` : ''}`);
|
|
265
265
|
else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
// 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
|
|
269
269
|
for (const g of input.supplyGaps || []) {
|
|
270
270
|
if (!g || !g.kind) continue;
|
|
271
|
-
if (/unavailable|stale|malformed|failed|unverified/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
271
|
+
if (/unavailable|stale|malformed|failed|unverified|refused|contradicts/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
|
|
272
272
|
else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
|
|
273
273
|
}
|
|
274
274
|
|
package/dist/8551.index.js
CHANGED
|
@@ -19,14 +19,14 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
19
19
|
|
|
20
20
|
// EXTERNAL MODULE: external "node:fs"
|
|
21
21
|
var external_node_fs_ = __webpack_require__(73024);
|
|
22
|
-
// EXTERNAL MODULE:
|
|
23
|
-
var
|
|
22
|
+
// EXTERNAL MODULE: external "node:crypto"
|
|
23
|
+
var external_node_crypto_ = __webpack_require__(77598);
|
|
24
|
+
// EXTERNAL MODULE: ./src/language/trusted-inputs.js
|
|
25
|
+
var trusted_inputs = __webpack_require__(6342);
|
|
24
26
|
// EXTERNAL MODULE: ./src/language/haskell-manifests.js
|
|
25
27
|
var haskell_manifests = __webpack_require__(16522);
|
|
26
28
|
// EXTERNAL MODULE: ./src/language/haskell-resolved-graph.js
|
|
27
29
|
var haskell_resolved_graph = __webpack_require__(86359);
|
|
28
|
-
// EXTERNAL MODULE: external "node:crypto"
|
|
29
|
-
var external_node_crypto_ = __webpack_require__(77598);
|
|
30
30
|
;// CONCATENATED MODULE: ./src/language/nix-closure.js
|
|
31
31
|
// Resolved derivation and closure inventory (NIX-008).
|
|
32
32
|
//
|
|
@@ -1032,6 +1032,7 @@ function mergeEvaluationHealth(scanHealth, result) {
|
|
|
1032
1032
|
|
|
1033
1033
|
|
|
1034
1034
|
|
|
1035
|
+
|
|
1035
1036
|
const RESOLVED_PASS_VERSION = 'resolved-pass/1';
|
|
1036
1037
|
|
|
1037
1038
|
const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
|
|
@@ -1071,6 +1072,12 @@ function resolvedHackageComponents(files) {
|
|
|
1071
1072
|
const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
|
|
1072
1073
|
const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
|
|
1073
1074
|
if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
|
|
1075
|
+
// The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
|
|
1076
|
+
// contradicts the freeze is stale or forged, and contributes no versions.
|
|
1077
|
+
const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
|
|
1078
|
+
const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
|
|
1079
|
+
if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
|
|
1080
|
+
gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
|
|
1074
1081
|
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
1075
1082
|
const direct = new Set((g.declared || []).map((d) => d.name));
|
|
1076
1083
|
const components = [];
|
|
@@ -1084,24 +1091,39 @@ function resolvedHackageComponents(files) {
|
|
|
1084
1091
|
}
|
|
1085
1092
|
|
|
1086
1093
|
// ── Nix closure ──────────────────────────────────────────────────────────────────
|
|
1087
|
-
function loadNixExports(files) {
|
|
1088
|
-
const exports = []; const sources = []; const problems = [];
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file:
|
|
1094
|
+
function loadNixExports(files, env = process.env) {
|
|
1095
|
+
const exports = []; const sources = []; const problems = []; const trust = new Set();
|
|
1096
|
+
const take = (label, text, origin) => {
|
|
1097
|
+
sources.push(label);
|
|
1098
|
+
const parsed = parseJson(text);
|
|
1099
|
+
if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
|
|
1093
1100
|
const j = parsed.value;
|
|
1094
|
-
|
|
1101
|
+
trust.add(origin);
|
|
1102
|
+
// NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
|
|
1103
|
+
// checked against; what an export must match is derived by the scanner (below) or comes from the operator.
|
|
1104
|
+
if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
|
|
1095
1105
|
else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
|
|
1096
|
-
else exports.push({ text
|
|
1106
|
+
else exports.push({ text });
|
|
1107
|
+
};
|
|
1108
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT) {
|
|
1109
|
+
try { take(env.AGENTIC_SECURITY_NIX_EXPORT, (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
|
|
1110
|
+
catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
|
|
1097
1111
|
}
|
|
1098
|
-
|
|
1112
|
+
for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
|
|
1113
|
+
// What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
|
|
1114
|
+
// without the lock it claims to describe being the lock that is here).
|
|
1115
|
+
const expected = {};
|
|
1116
|
+
const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
|
|
1117
|
+
if (lockPath) expected.flakeLockSha256 = (0,external_node_crypto_.createHash)('sha256').update(files[lockPath]).digest('hex');
|
|
1118
|
+
else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
|
|
1119
|
+
if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
|
|
1120
|
+
return { exports, sources, problems, expected, trust: [...trust] };
|
|
1099
1121
|
}
|
|
1100
1122
|
|
|
1101
1123
|
/** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
|
|
1102
1124
|
function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
1103
1125
|
return memoized(files, 'nix-closure', () => {
|
|
1104
|
-
const { exports, sources, problems, expected } = loadNixExports(files);
|
|
1126
|
+
const { exports, sources, problems, expected, trust } = loadNixExports(files);
|
|
1105
1127
|
if (!sources.length) return null;
|
|
1106
1128
|
const closure = importNixClosure({ exports, expected, now });
|
|
1107
1129
|
const drvEnv = {};
|
|
@@ -1110,15 +1132,19 @@ function nixClosureOf(files, { now = Date.now() } = {}) {
|
|
|
1110
1132
|
let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
|
|
1111
1133
|
for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
|
|
1112
1134
|
}
|
|
1113
|
-
|
|
1135
|
+
// A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
|
|
1136
|
+
// signature) means its contents are not evidence about this build: it is refused, not merely labelled.
|
|
1137
|
+
const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
|
|
1138
|
+
const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
|
|
1139
|
+
return { closure, drvEnv, sources, problems, trust, refused };
|
|
1114
1140
|
});
|
|
1115
1141
|
}
|
|
1116
1142
|
|
|
1117
1143
|
/** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
|
|
1118
1144
|
function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
|
|
1119
|
-
const
|
|
1120
|
-
if (!path) return { data: null, reason:
|
|
1121
|
-
let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
|
|
1145
|
+
const sel = (0,trusted_inputs/* resolveOperatorSnapshot */.Rn)({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
|
|
1146
|
+
if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? (0,trusted_inputs/* IGNORED_NOTE */.cY)('nix-advisories.json') : ''}` };
|
|
1147
|
+
let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
|
|
1122
1148
|
const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
|
|
1123
1149
|
return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
|
|
1124
1150
|
}
|
|
@@ -1134,9 +1160,11 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
|
|
|
1134
1160
|
const adv = configuredNixAdvisories(scanRoot, env, hackage);
|
|
1135
1161
|
let usage = null; try { usage = (0,haskell_supply/* collectUsage */.c$)(files); } catch { usage = null; }
|
|
1136
1162
|
let matched = null;
|
|
1137
|
-
try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
1163
|
+
if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
|
|
1138
1164
|
const gaps = [];
|
|
1165
|
+
if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
|
|
1139
1166
|
for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
|
|
1167
|
+
for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
|
|
1140
1168
|
for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
|
|
1141
1169
|
if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
|
|
1142
1170
|
else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
|