@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.28

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 (37) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/agentic-security.js +35 -4
  3. package/dist/{90ebba38d8860ec77376.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
  4. package/dist/5856.index.js +634 -0
  5. package/dist/7033.index.js +74 -14
  6. package/dist/9220.index.js +24 -1
  7. package/dist/agentic-security.mjs +9 -9
  8. package/dist/agentic-security.mjs.sha256 +1 -1
  9. package/package.json +4 -3
  10. package/src/dataflow/catalog.js +42 -1
  11. package/src/engine.js +90 -10
  12. package/src/language/CLAUDE.md +6 -0
  13. package/src/language/assurance.js +53 -2
  14. package/src/language/haskell-budget-state.js +85 -0
  15. package/src/language/haskell-coverage.js +230 -0
  16. package/src/language/haskell-project-models.js +107 -0
  17. package/src/language/nix-eval-isolation.js +74 -14
  18. package/src/llm-validator/bounded-context.js +87 -0
  19. package/src/llm-validator/claim-groups.js +105 -0
  20. package/src/llm-validator/disposition.js +264 -0
  21. package/src/llm-validator/index.js +52 -26
  22. package/src/pipeline/evidence-record.js +112 -0
  23. package/src/pipeline/scan-health.js +12 -1
  24. package/src/posture/artifact-registry.js +2 -0
  25. package/src/posture/drift.js +19 -1
  26. package/src/posture/reachability-filter.js +3 -3
  27. package/src/posture/suppressions.js +21 -0
  28. package/src/posture/watch-mode.js +22 -1
  29. package/src/report/disposition-view.js +90 -0
  30. package/src/report/index.js +47 -0
  31. package/src/runScan.js +60 -6
  32. package/src/sast/oidc-trust.js +74 -0
  33. package/src/sast/pipeline.js +14 -1
  34. package/src/sast/workflow-flow.js +272 -0
  35. package/src/sca/basis.js +88 -0
  36. package/src/scope/flags.js +55 -0
  37. package/src/scope/inventory.js +241 -0
@@ -0,0 +1,112 @@
1
+ // Evidence contract (PRD FR 10): additive scan-record and finding-record fields. Nothing here replaces an existing
2
+ // field; every field is derived from data the scan already holds or from read-only git queries on the scan root.
3
+
4
+ import * as crypto from 'node:crypto';
5
+ import * as cp from 'node:child_process';
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
9
+
10
+ export const SCAN_RECORD_SCHEMA = 'scan-record/1';
11
+ export const FINDING_RECORD_SCHEMA = 'finding-record/1';
12
+ const DIRTY_CAP = 200;
13
+
14
+ const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
15
+
16
+ function git(root, args) {
17
+ const r = cp.spawnSync('git', hardenGitArgs(args), { cwd: root, env: hardenGitEnv({}), encoding: 'buffer', maxBuffer: 64 * 1024 * 1024 });
18
+ return r.status === 0 ? r.stdout.toString('utf8') : null;
19
+ }
20
+
21
+ /** Source commit and digests of the files that differ from it, so a result is tied to the exact code scanned. */
22
+ export function sourceState(root) {
23
+ const inside = git(root, ['rev-parse', '--is-inside-work-tree']);
24
+ if (!inside || inside.trim() !== 'true') return { vcs: 'none', commit: null, dirty: null, dirtyFiles: [], dirtyFilesTruncated: false };
25
+ const commit = (git(root, ['rev-parse', 'HEAD']) || '').trim() || null;
26
+ const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']);
27
+ const entries = (status || '').split('\0').filter(Boolean).map((e) => ({ code: e.slice(0, 2), file: e.slice(3) }));
28
+ const dirtyFiles = entries.slice(0, DIRTY_CAP).map((e) => {
29
+ let digest = null;
30
+ try { const abs = path.join(root, e.file); if (fs.lstatSync(abs).isFile()) digest = sha(fs.readFileSync(abs)); } catch { /* deleted */ }
31
+ return { file: e.file, status: e.code.trim() || '?', sha256: digest };
32
+ });
33
+ return { vcs: 'git', commit, dirty: entries.length > 0, dirtyFiles, dirtyFilesTruncated: entries.length > DIRTY_CAP, dirtyCount: entries.length };
34
+ }
35
+
36
+ /** Flags that shaped the run, with values that could carry secrets or local paths reduced to their names. */
37
+ export function invocationSummary(argv) {
38
+ const out = [];
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (!a.startsWith('--')) continue;
42
+ let [k, v] = a.split(/=(.*)/s, 2);
43
+ // `--flag value`: keep a short non-path value so the record shows the format and mode that were chosen.
44
+ if (v === undefined && argv[i + 1] !== undefined && !argv[i + 1].startsWith('-') && /^[A-Za-z0-9_.,:-]{1,40}$/.test(argv[i + 1]) && !argv[i + 1].includes('/')) v = argv[i + 1];
45
+ out.push(/key|token|secret|password/i.test(k) ? `${k}=<redacted>` : (v !== undefined ? `${k}=${v}` : k));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ export function buildScanRecord({ root, scan, meta, argv = [], engineVersion, mode }) {
51
+ const h = scan.scanHealth || {};
52
+ const model = scan.aiAssistance || null;
53
+ return {
54
+ schema: SCAN_RECORD_SCHEMA,
55
+ tool: { name: 'agentic-security', version: engineVersion || null },
56
+ source: sourceState(root),
57
+ invocation: { flags: invocationSummary(argv), mode: mode || (meta && meta.mode) || 'full' },
58
+ ignorePolicy: scan.scope ? { mode: scan.scope.policy.mode, ruleDigest: scan.scope.policy.ruleDigest, trackedFileSemantics: scan.scope.policy.trackedFileSemantics } : { mode: 'legacy-walk', ruleDigest: null, trackedFileSemantics: false },
59
+ rules: { rulesetVersion: scan._rulesetVersion || null, rulesetSource: scan._rulesetVersionSource || null },
60
+ model: model ? { provider: model.provider, model: model.model, egress: model.egress, stages: model.stages } : { configured: false },
61
+ capability: { status: h.status || null, conditions: Array.isArray(h.conditions) ? h.conditions.length : 0 },
62
+ fileCounts: { scanned: scan.filesScanned ?? null, skipped: scan.scope ? scan.scope.skippedCount : null, included: scan.scope ? scan.scope.includedCount : null },
63
+ // Wall-clock time differs on every run, so deterministic mode leaves it out and the record stays byte-identical.
64
+ elapsedMs: process.env.AGENTIC_SECURITY_DETERMINISTIC === '1' ? null : (meta && meta.durationMs != null ? meta.durationMs : null),
65
+ };
66
+ }
67
+
68
+ /** The span a finding occupies: source line through sink line when both are known, else its own line. */
69
+ function spanOf(f) {
70
+ const lines = [f.line, f.source && f.source.line, f.sink && f.sink.line].filter((n) => Number.isInteger(n));
71
+ if (!lines.length) return null;
72
+ return { startLine: Math.min(...lines), endLine: Math.max(...lines) };
73
+ }
74
+
75
+ /** Finding record. `fileContents` maps scan-relative path to text. Existing fields are untouched. */
76
+ export function annotateFindingRecords(findings, fileContents, { engineVersion = null } = {}) {
77
+ for (const f of findings || []) {
78
+ if (!f || f.record) continue;
79
+ const text = fileContents && (fileContents instanceof Map ? fileContents.get(f.file) : fileContents[f.file]);
80
+ f.record = {
81
+ schema: FINDING_RECORD_SCHEMA,
82
+ stableId: f.stableId || null,
83
+ path: f.file || null,
84
+ sourceHash: typeof text === 'string' ? sha(text) : null,
85
+ span: spanOf(f),
86
+ ruleFamily: f.family || null,
87
+ ruleVersion: engineVersion,
88
+ originalSeverity: f.originalSeverity || f.severity || null,
89
+ confidence: typeof f.confidence === 'number' ? f.confidence : null,
90
+ disposition: f.disposition || null,
91
+ evidence: {
92
+ sourceToSink: f.source || f.sink ? { source: f.source || null, sink: f.sink || null } : null,
93
+ guards: f.falsification ? [f.falsification] : [],
94
+ missingEvidence: (f.dispositionDetail && f.dispositionDetail.missingEvidence) || [],
95
+ },
96
+ reviewerVerdicts: Array.isArray(f.reviewerVerdicts) ? f.reviewerVerdicts : [],
97
+ modelOpinion: f.validator_verdict ? { verdict: f.validator_verdict, confidence: f.llm_confidence ?? null } : null,
98
+ remediation: f.fix || f.remediation || null,
99
+ };
100
+ }
101
+ return findings;
102
+ }
103
+
104
+ /**
105
+ * Raw and displayed findings must reconcile: every raw item is either displayed or accounted for by a named reason.
106
+ * Returns { ok, raw, displayed, accounted, gap }.
107
+ */
108
+ export function reconcileFindings({ raw, displayed, hidden = {} }) {
109
+ const accounted = Object.values(hidden).reduce((a, b) => a + b, 0);
110
+ const gap = raw - displayed - accounted;
111
+ return { ok: gap === 0, raw, displayed, accounted, gap };
112
+ }
@@ -42,6 +42,17 @@ export const SCAN_HEALTH_SCHEMA_VERSION = 1;
42
42
  * never a fabricated all-zero summary.
43
43
  * @returns {object} scanHealth per PRD §10.3, additive fields only.
44
44
  */
45
+ // The per-file table travels with the record up to this many files; beyond it only the count is kept
46
+ // (the aggregate stages, rates and incomplete lists above are always present).
47
+ const HASKELL_BYFILE_INLINE_MAX = 2000;
48
+ function summarizeHaskellCoverage(h) {
49
+ if (!h) return null;
50
+ const n = h.byFile ? Object.keys(h.byFile).length : 0;
51
+ if (n <= HASKELL_BYFILE_INLINE_MAX) return { ...h, byFileCount: n };
52
+ const { byFile, ...rest } = h;
53
+ return { ...rest, byFileCount: n, byFileOmitted: true };
54
+ }
55
+
45
56
  export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engineErrors = null, deepStatus = null, analyzerCoverage = null, lineageStatus = null, languageCoverage = null, supplyChain = null } = {}) {
46
57
  const conditions = [];
47
58
  const safeAnnotatorErrors = Array.isArray(annotatorErrors) ? annotatorErrors : [];
@@ -130,7 +141,7 @@ export function computeScanHealth({ scanMeta = null, annotatorErrors = [], engin
130
141
  schemaVersion: SCAN_HEALTH_SCHEMA_VERSION,
131
142
  status,
132
143
  ...(trustFindings.length ? { buildTrust: { findings: trustFindings.length, uncertain: trustUncertain } } : {}),
133
- ...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}) } } : {}),
144
+ ...(languageCoverage ? { languageCoverage: { totals: languageCoverage.totals ?? null, byKind: languageCoverage.byKind ?? null, ...(languageCoverage.limitations ? { limitations: languageCoverage.limitations } : {}), ...(languageCoverage.capabilities ? { capabilities: languageCoverage.capabilities } : {}), ...(languageCoverage.optionalModes ? { optionalModes: languageCoverage.optionalModes } : {}), ...(languageCoverage.haskell ? { haskell: summarizeHaskellCoverage(languageCoverage.haskell) } : {}) } } : {}),
134
145
  files: {
135
146
  expected: scanMeta?.checkpoint?.total ?? null,
136
147
  scanned: scanMeta?.filesScanned ?? null,
@@ -253,6 +253,8 @@ export const ARTIFACT_REGISTRY = [
253
253
  { name: 'integrations.yml', kind: 'file', classification: 'operator-config' },
254
254
  { name: 'profile.yml', kind: 'file', classification: 'operator-config' },
255
255
  { name: 'sca-policy.yml', kind: 'file', classification: 'operator-config' },
256
+ { name: 'haskell-budget.json', kind: 'file', classification: 'generated', retentionClass: 'scan', source: 'src/language/haskell-budget-state.js (saveBudgetState)', note: 'Haskell parse budgets in effect and every file left unanalysed when one was hit, with a content digest, so the next scan can say what finished since. Rewritten by every scan that has Haskell work left.' },
257
+ { name: 'haskell-models.json', kind: 'file', classification: 'operator-config', note: 'reviewed project models for Haskell wrappers; only ever loaded by language/haskell-project-models.js' },
256
258
  { name: 'suppressions.yml', kind: 'file', classification: 'operator-config', note: 'audit-tier suppression config; only ever loaded, never saved, by posture/suppressions.js' },
257
259
  { name: 'network-policy.json', kind: 'file', classification: 'operator-config', note: 'documented as an Inputs-section artifact in posture/network-policy-import.js, not a scanner-written digest' },
258
260
  { name: 'privacy-taxonomy.json', kind: 'file', classification: 'operator-config', note: 'FR-402 privacy data-classification taxonomy overrides/additions — read by dataflow/privacy-taxonomy.js, never written by the scanner' },
@@ -78,7 +78,21 @@ export function driftBetween(scanA, scanB) {
78
78
  // Findings — use kind+file+line+vuln as the stable key.
79
79
  const allFindingsA = [...(scanA.findings || []), ...(scanA.logicVulns || [])];
80
80
  const allFindingsB = [...(scanB.findings || []), ...(scanB.logicVulns || [])];
81
- const findings = _diffSets(allFindingsA, allFindingsB, _findingKey);
81
+ // A claim grouped under an equivalent primary (llm-validator/claim-groups.js) is still present: it keeps its identity for drift,
82
+ // so grouping never shows a finding as fixed, and ungrouping never shows one as new.
83
+ const findings = _diffSets([...allFindingsA, ...(scanA.groupedClaims || [])], [...allFindingsB, ...(scanB.groupedClaims || [])], _findingKey);
84
+ // PRD FR 01: a finding that disappeared because the newer scan did not cover its file is out of scope, never fixed.
85
+ const outOfScope = [];
86
+ if (scanB.scope && Array.isArray(scanB.scope.included)) {
87
+ const inB = new Set(scanB.scope.included);
88
+ const keep = [];
89
+ for (const f of findings.removed) (f && f.file && !inB.has(f.file) ? outOfScope : keep).push(f);
90
+ findings.removed = keep;
91
+ findings.outOfScope = outOfScope;
92
+ } else {
93
+ findings.outOfScope = [];
94
+ findings.scopeUnknown = !!(scanB.scope && scanB.scope.includedOmitted);
95
+ }
82
96
  const severity = _severityDelta(allFindingsA, allFindingsB);
83
97
  const authBoundaries = _authBoundaryDelta(scanA.routes || [], scanB.routes || []);
84
98
  const dataClasses = _dataClassDelta(scanA.routes || [], scanB.routes || []);
@@ -163,6 +177,10 @@ export function driftToMarkdown(drift) {
163
177
  lines.push(`**Findings fixed: ${drift.findings.removed.length}**`);
164
178
  lines.push('');
165
179
  }
180
+ if (drift.findings.outOfScope && drift.findings.outOfScope.length) {
181
+ lines.push(`**Findings not covered by this scan (out of scope, not fixed): ${drift.findings.outOfScope.length}**`);
182
+ lines.push('');
183
+ }
166
184
  if (drift.dataClasses.newlyExposed.length) {
167
185
  lines.push(`**Newly exposed data classes: ${drift.dataClasses.newlyExposed.join(', ')}**`);
168
186
  lines.push('');
@@ -25,12 +25,12 @@ const SEVERITY_DEMOTE = {
25
25
  // keeps full severity. route-reachable-via-function and function-reachable
26
26
  // both keep full severity because the vulnerable function is provably
27
27
  // called; import-reachable also keeps full severity (imported = uncertain
28
- // but plausible). The lower three tiers get demoted.
28
+ // but plausible). manifest-only and transitive-only record only that no use was
29
+ // observed, which is unknown reachability, and unknown reachability cannot lower
30
+ // severity (PRD FR 08). Only positive evidence (unreachable, build-only) demotes.
29
31
  const DEMOTE_SCA_TIERS = new Set([
30
32
  'unreachable', // function never called from project
31
33
  'build-only', // dev/build-time dependency only
32
- 'manifest-only', // declared, but no use observed
33
- 'transitive-only', // transitive dep, scope unclear
34
34
  ]);
35
35
 
36
36
  export function demoteUnreachable(findings, opts = {}) {
@@ -39,6 +39,7 @@ import * as path from 'node:path';
39
39
  import * as yaml from '../util/yaml.js';
40
40
  import { statePath, safeWriteState } from './state-dir.js';
41
41
  import { loadApproverRegistry, verifyApprover, requiredRolesFor } from '../fix/approver-registry.js';
42
+ import { contentFingerprint } from '../llm-validator/disposition.js';
42
43
 
43
44
  const MS_PER_DAY = 86400000;
44
45
  const SOFT_TTL_DAYS = 30;
@@ -63,6 +64,15 @@ export function saveSoftAccepted(scanRoot, items) {
63
64
  safeWriteState(fp, JSON.stringify({ accepted: items }, null, 2));
64
65
  }
65
66
 
67
+ // Content fingerprint of a scanned file, or null when it cannot be read.
68
+ function _diskFingerprint(scanRoot, file) {
69
+ try {
70
+ const abs = path.resolve(scanRoot, file);
71
+ if (abs !== path.resolve(scanRoot) && !abs.startsWith(path.resolve(scanRoot) + path.sep)) return null;
72
+ return contentFingerprint(fs.readFileSync(abs, 'utf8'));
73
+ } catch { return null; }
74
+ }
75
+
66
76
  export function addSoftAcceptance(scanRoot, finding, reason) {
67
77
  const items = loadSoftAccepted(scanRoot);
68
78
  const expires = new Date(_now() + SOFT_TTL_DAYS * MS_PER_DAY).toISOString().slice(0, 10);
@@ -74,6 +84,8 @@ export function addSoftAcceptance(scanRoot, finding, reason) {
74
84
  reason: reason || 'vibecoded for now',
75
85
  accepted_at: new Date().toISOString().slice(0, 10),
76
86
  expires_at: expires,
87
+ // PRD FR 05: bind the acceptance to the code it was made on, so a changed file reopens it.
88
+ fingerprint: _diskFingerprint(scanRoot, finding.file),
77
89
  });
78
90
  saveSoftAccepted(scanRoot, items);
79
91
  return expires;
@@ -156,6 +168,15 @@ export function applySuppressions(findings, scanRoot, profile) {
156
168
  kept.push({ ...f, _suppressionExpired: true });
157
169
  continue;
158
170
  }
171
+ // PRD FR 05: an entry bound to a content fingerprint reopens when the
172
+ // file changes or disappears. Entries without one keep the legacy behaviour.
173
+ if (matched.fingerprint) {
174
+ const now_fp = _diskFingerprint(scanRoot, f.file);
175
+ if (now_fp !== matched.fingerprint) {
176
+ kept.push({ ...f, _suppressionReopened: now_fp ? 'source changed since the suppression was recorded' : 'suppressed source is no longer available' });
177
+ continue;
178
+ }
179
+ }
159
180
  // Pro: validate the entry still passes
160
181
  if (isPro) {
161
182
  // Same registry/category shape FR-307 already uses for high-impact
@@ -25,6 +25,7 @@ import * as path from 'node:path';
25
25
 
26
26
  import { stateDir, statePath, stateWritesEnabled } from './state-dir.js';
27
27
  import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, isExplicitExport, impactedFiles } from '../language/discovery.js';
28
+ import { buildScanInventory } from '../scope/inventory.js';
28
29
  const STATUS_MD = 'watch-status.md';
29
30
  const STATUS_JSON = 'watch-status.json';
30
31
  const DEBOUNCE_MS = 350;
@@ -33,6 +34,25 @@ const MAX_BURST = 50; // ignore beyond this # of rapid events
33
34
  const SCAN_EXT_RE = /\.(?:[jt]sx?|mjs|cjs|py|java|kt|go|rb|php|cs|c|cc|cpp|h|hpp|rs|sol|vy|swift|dart|toml|yml|yaml|json|tf|tfvars|bicep)$/i;
34
35
  const IGNORE_DIR_RE = /(?:^|\/)(?:\.git|node_modules|\.bench-cache|dist|build|\.next|coverage|\.agentic-security)(?:$|\/)/;
35
36
 
37
+ // Under Git scope (PRD FR 01) the watcher asks the same inventory the scan uses, so a change to a Git-ignored file does not
38
+ // trigger a rescan, and a tracked file under a directory the default list would skip (`dist/`, `build/`) still does. A change
39
+ // to an ignore source rebuilds the inventory. A path the inventory has not seen (a new file) rebuilds it, at most once a second.
40
+ const IGNORE_SOURCE = /(?:^|\/)\.gitignore$|^\.git\/info\/exclude$/;
41
+ export function createScopeFilter(scanRoot, scope = {}, { build = buildScanInventory, now = Date.now } = {}) {
42
+ let included = null;
43
+ let builtAt = -Infinity;
44
+ const rebuild = () => { included = new Set(build(scanRoot, scope).included); builtAt = now(); };
45
+ rebuild();
46
+ return (rel) => {
47
+ if (!rel || /^\.agentic-security(?:\/|$)/.test(rel)) return false;
48
+ if (IGNORE_SOURCE.test(rel)) { rebuild(); return true; }
49
+ if (/^\.git(?:\/|$)/.test(rel)) return false;
50
+ if (included.has(rel)) return true;
51
+ if (now() - builtAt >= 1000) rebuild();
52
+ return included.has(rel);
53
+ };
54
+ }
55
+
36
56
  function _isScanable(rel) {
37
57
  if (!rel) return false;
38
58
  // An explicit export is evidence even though it lives in a build directory.
@@ -162,6 +182,7 @@ export function readStatus(scanRoot) {
162
182
  export async function watchProject(scanRoot, onChange, opts = {}) {
163
183
  if (process.env.AGENTIC_SECURITY_NO_WATCH === '1') return { stop: async () => {}, _disabled: true };
164
184
  const recursive = opts.recursive !== false;
185
+ const eligible = typeof opts.isEligible === 'function' ? opts.isEligible : _isScanable;
165
186
  const ac = new AbortController();
166
187
  let timer = null;
167
188
  const pending = new Set();
@@ -183,7 +204,7 @@ export async function watchProject(scanRoot, onChange, opts = {}) {
183
204
  for await (const evt of fs.watch(scanRoot, { recursive, signal: ac.signal })) {
184
205
  if (stopped) break;
185
206
  const rel = String(evt.filename || '').replace(/\\/g, '/');
186
- if (!_isScanable(rel)) continue;
207
+ if (!eligible(rel)) continue;
187
208
  pending.add(path.join(scanRoot, rel));
188
209
  if (timer) clearTimeout(timer);
189
210
  timer = setTimeout(flush, DEBOUNCE_MS);
@@ -0,0 +1,90 @@
1
+ // Report view over dispositions (PRD FR 09). Display only: it reads a scan and
2
+ // returns groups and counts, and never changes stored findings, so hiding a
3
+ // category here cannot mark anything assessed or imply remediation. Raw and
4
+ // displayed counts reconcile: raw = shown + every hidden bucket.
5
+
6
+ import { DISPOSITIONS as D } from '../llm-validator/disposition.js';
7
+
8
+ export const VIEW_SCHEMA = 'report-view/1';
9
+ export const EXCLUDABLE_KINDS = Object.freeze(['sca', 'generated', 'secrets', 'logic']);
10
+
11
+ const LANG_BY_EXT = { js: 'javascript', mjs: 'javascript', cjs: 'javascript', ts: 'typescript', tsx: 'typescript', jsx: 'javascript', py: 'python', java: 'java', go: 'go', rb: 'ruby', rs: 'rust', cs: 'csharp', kt: 'kotlin', php: 'php', hs: 'haskell', lhs: 'haskell', nix: 'nix', yml: 'yaml', yaml: 'yaml', tf: 'terraform' };
12
+ export function languageOf(file) {
13
+ const m = /\.([A-Za-z0-9]+)$/.exec(String(file || ''));
14
+ return (m && LANG_BY_EXT[m[1].toLowerCase()]) || 'other';
15
+ }
16
+
17
+ const GENERATED = /(?:^|\/)(?:dist|build|buck-out|dist-newstyle|\.stack-work|generated|gen)\//i;
18
+
19
+ function groupOf(f) {
20
+ const d = f.disposition;
21
+ if (d === D.CONFIRMED) return 'confirmed';
22
+ if (d === D.HARDENING) return 'hardening';
23
+ if (d === D.REFUTED) return 'history';
24
+ if (d === D.NEEDS_REVIEW) return 'needsReview';
25
+ return 'unvalidated';
26
+ }
27
+
28
+ /** Parse `--exclude-kind sca,generated` style values. Returns { kinds, errors }. */
29
+ export function parseExcludeKinds(v) {
30
+ const kinds = []; const errors = [];
31
+ for (const k of String(v ?? '').split(',').map((s) => s.trim()).filter(Boolean)) {
32
+ if (EXCLUDABLE_KINDS.includes(k)) kinds.push(k); else errors.push(`--exclude-kind: unknown kind "${k}" (known: ${EXCLUDABLE_KINDS.join(', ')})`);
33
+ }
34
+ return { kinds, errors };
35
+ }
36
+
37
+ /**
38
+ * @param {object} scan
39
+ * @param {{excludeKinds?: string[], filters?: {category?: string, language?: string, package?: string, path?: string, disposition?: string, minConfidence?: number}}} [o]
40
+ */
41
+ export function buildDispositionView(scan, { excludeKinds = [], filters = {} } = {}) {
42
+ const buckets = [
43
+ ['code', scan.findings || [], null],
44
+ ['secrets', scan.secrets || [], 'secrets'],
45
+ ['logic', scan.logicVulns || [], 'logic'],
46
+ ['sca', scan.supplyChain || [], 'sca'],
47
+ ];
48
+ const hidden = { byKind: {}, byFilter: 0 };
49
+ const groups = { confirmed: [], needsReview: [], unvalidated: [], hardening: [], history: [] };
50
+ let raw = 0;
51
+ for (const [bucket, items, kind] of buckets) {
52
+ for (const f of items) {
53
+ raw++;
54
+ const isSca = bucket === 'sca';
55
+ const k = kind || (GENERATED.test(String(f.file || '')) ? 'generated' : null);
56
+ if (k && excludeKinds.includes(k)) { hidden.byKind[k] = (hidden.byKind[k] || 0) + 1; continue; }
57
+ if (filters.category && String(f.family || f.category || '') !== filters.category) { hidden.byFilter++; continue; }
58
+ if (filters.language && languageOf(f.file) !== filters.language) { hidden.byFilter++; continue; }
59
+ if (filters.package && String(f.package || f.component || f.name || '') !== filters.package) { hidden.byFilter++; continue; }
60
+ if (filters.path && !String(f.file || '').startsWith(filters.path)) { hidden.byFilter++; continue; }
61
+ if (filters.disposition && (isSca ? 'unvalidated' : groupOf(f)) !== filters.disposition && f.disposition !== filters.disposition) { hidden.byFilter++; continue; }
62
+ if (typeof filters.minConfidence === 'number' && (typeof f.confidence === 'number' ? f.confidence : 1) < filters.minConfidence) { hidden.byFilter++; continue; }
63
+ groups[isSca ? 'unvalidated' : groupOf(f)].push(f);
64
+ }
65
+ }
66
+ // Refuted findings were moved to the suppression ledger by the engine; they appear as history.
67
+ for (const s of scan.suppressions || []) if (s && s.disposition === D.REFUTED) groups.history.push(s);
68
+ const counts = Object.fromEntries(Object.entries(groups).map(([k, v]) => [k, v.length]));
69
+ // History comes from the ledger, not from the live buckets, so it is outside the raw/shown reconciliation.
70
+ const shown = counts.confirmed + counts.needsReview + counts.unvalidated + counts.hardening;
71
+ const hiddenTotal = Object.values(hidden.byKind).reduce((a, b) => a + b, 0) + hidden.byFilter;
72
+ return {
73
+ schema: VIEW_SCHEMA,
74
+ excludeKinds: [...excludeKinds],
75
+ filters: { ...filters },
76
+ counts,
77
+ actionable: counts.confirmed + counts.needsReview,
78
+ raw, shown, hidden,
79
+ reconciles: raw === shown + hiddenTotal,
80
+ note: hiddenTotal ? 'Hidden items are still stored in the scan and are not assessed or remediated by hiding them.' : null,
81
+ };
82
+ }
83
+
84
+ export function viewLine(v) {
85
+ if (!v) return null;
86
+ const hid = Object.entries(v.hidden.byKind).map(([k, n]) => `${n} ${k}`);
87
+ if (v.hidden.byFilter) hid.push(`${v.hidden.byFilter} filtered`);
88
+ return `Findings: ${v.counts.confirmed} confirmed, ${v.counts.needsReview} needs review, ${v.counts.unvalidated} unvalidated, ${v.counts.hardening} hardening advice, ${v.counts.history} refuted history`
89
+ + (hid.length ? `; hidden from this view (still stored, not assessed): ${hid.join(', ')}` : '');
90
+ }
@@ -1,4 +1,5 @@
1
1
  // Report writers — JSON / Markdown / SARIF.
2
+ import { viewLine } from './disposition-view.js';
2
3
  import * as crypto from 'node:crypto';
3
4
  import { _isCustomSuppressed } from '../engine.js';
4
5
  import { alertFace, approveFace } from './mascot.js';
@@ -290,6 +291,14 @@ export function normalizeFindings(scan){
290
291
  blastRadius: f.blastRadius || null,
291
292
  // Sentinel-parity (FR-PREC, FR-L3) preserved fields.
292
293
  stableId: f.stableId || null,
294
+ // FR 05 / FR 10: the final disposition, why, and the evidence record. Present only when the engine computed them.
295
+ ...(f.disposition ? { disposition: f.disposition, dispositionDetail: f.dispositionDetail || null } : {}),
296
+ ...(Array.isArray(f.claims) && f.claims.length > 1 ? { claims: f.claims } : {}),
297
+ ...(f.record ? { record: f.record } : {}),
298
+ // FR 06: every workflow finding names the workflow, job, step, input origin, sensitive operation and privilege boundary.
299
+ ...(f.workflow ? { workflow: f.workflow, job: f.job ?? null, step: f.step ?? null, inputOrigin: f.inputOrigin ?? null, inputOriginKind: f.inputOriginKind ?? null, sensitiveOperation: f.sensitiveOperation ?? null, privilegeBoundary: f.privilegeBoundary ?? null, guards: f.guards ?? [], missingEvidence: f.missingEvidence ?? (f.dispositionDetail && f.dispositionDetail.missingEvidence) ?? [] } : {}),
300
+ ...(f.oidcTrust ? { oidcTrust: f.oidcTrust } : {}),
301
+ ...(f.validator_reject_retained ? { validator_reject_retained: true } : {}),
293
302
  confidenceTier: f.confidenceTier || null,
294
303
  exploitability: typeof f.exploitability === 'number' ? f.exploitability : null,
295
304
  exploitabilityTier: f.exploitabilityTier || null,
@@ -569,6 +578,8 @@ export function normalizeFindings(scan){
569
578
  version: sc.version,
570
579
  cveAliases: sc.cveAliases || [],
571
580
  osvId: sc.osvId || null,
581
+ ...(sc.basis ? { basis: sc.basis } : {}),
582
+ ...(sc.advisoryIds ? { advisoryIds: sc.advisoryIds, occurrences: sc.occurrences } : {}),
572
583
  advisory: sc.advisory || sc.description || sc.summary || '',
573
584
  // Hackage (and any matcher that decides from ranges): WHY the dependency matched. Without these a `possibly-affected` finding
574
585
  // (only a declared range is known) reads exactly like an `affected` one (the resolved version is inside the range).
@@ -764,6 +775,13 @@ export function toJSON(scan, meta={}, opts={}){
764
775
  // "analysis complete". Null when the engine did not compute one (e.g. a
765
776
  // hand-built `scan` object in a test), same convention as the S7 fields
766
777
  // above; a real scan always computes it.
778
+ ...(scan.scanRecord ? { scanRecord: scan.scanRecord } : {}),
779
+ ...(scan.reportView ? { reportView: scan.reportView } : {}),
780
+ ...(scan.dispositionCounts ? { dispositionCounts: scan.dispositionCounts } : {}),
781
+ // PRD FR 05: every candidate stays discoverable. Equivalent claims grouped under a primary are listed here, and the
782
+ // reconciliation shows raw, grouped, refuted and active counts with nothing counted twice.
783
+ ...(scan.candidateReconciliation ? { candidateReconciliation: scan.candidateReconciliation } : {}),
784
+ ...(Array.isArray(scan.groupedClaims) && scan.groupedClaims.length ? { groupedClaims: scan.groupedClaims.map((g) => ({ id: g.id ?? null, stableId: g.stableId ?? null, parser: g.parser ?? null, vuln: g.vuln ?? null, cwe: g.cwe ?? null, severity: g.severity ?? null, file: g.file ?? null, line: g.line ?? null, groupedInto: g.groupedInto ?? null, disposition: g.disposition ?? null })) } : {}),
767
785
  scanHealth: scan.scanHealth || null,
768
786
  // X-207: advisory hypotheses a caller put in the finding list; excluded from `findings`, listed here so it is never silent.
769
787
  ...(Array.isArray(scan.advisoryExcluded) && scan.advisoryExcluded.length ? { advisoryExcluded: scan.advisoryExcluded } : {}),
@@ -1079,6 +1097,29 @@ export const TOOL_CAVEATS = Object.freeze([
1079
1097
  },
1080
1098
  ]);
1081
1099
 
1100
+ // FR 02: one reading of the Haskell coverage record, shared by the CLI, HTML and SARIF outputs so they cannot disagree.
1101
+ export function haskellCoverageSummary(scan) {
1102
+ const h = scan && scan.scanHealth && scan.scanHealth.languageCoverage && scan.scanHealth.languageCoverage.haskell;
1103
+ if (!h || !h.present) return null;
1104
+ const pct = (r) => (r == null ? 'n/a' : `${(r * 100).toFixed(1)}%`);
1105
+ return {
1106
+ status: h.capability.status,
1107
+ discovered: h.totals.discovered, eligible: h.totals.eligible, eligibleHs: h.totals.eligibleHs,
1108
+ parseRate: h.stages.parse.rate, semanticRate: h.stages.semantic.rate,
1109
+ parseText: pct(h.stages.parse.rate), semanticText: pct(h.stages.semantic.rate),
1110
+ functions: h.functions, unresolvedCalls: h.unresolvedCalls,
1111
+ ruleFamilies: h.ruleFamilies.map((f) => ({ id: f.id, status: f.status, findings: f.findings })),
1112
+ reasons: h.capability.reasons, limitations: h.capability.limitations,
1113
+ incompleteEntries: h.incompleteEntries.length,
1114
+ };
1115
+ }
1116
+
1117
+ function haskellCoverageLine(scan) {
1118
+ const h = haskellCoverageSummary(scan);
1119
+ if (!h) return null;
1120
+ return `Haskell coverage: ${h.status}; ${h.eligibleHs} eligible .hs file(s), parsed ${h.parseText}, semantic analysis completed ${h.semanticText}; ${h.functions.lowered}/${h.functions.discovered} function(s) analysed, ${h.unresolvedCalls == null ? 'n/a' : h.unresolvedCalls} unresolved call(s)`;
1121
+ }
1122
+
1082
1123
  export function toSARIF(scan, meta={}){
1083
1124
  const findings = normalizeFindings(scan);
1084
1125
  const ruleMap = new Map();
@@ -1128,6 +1169,8 @@ export function toSARIF(scan, meta={}){
1128
1169
  // /security-trend regression analysis can attribute deltas to rule
1129
1170
  // changes vs. code changes.
1130
1171
  properties: {
1172
+ ...(haskellCoverageSummary(scan) ? { haskellCoverage: haskellCoverageSummary(scan) } : {}),
1173
+ ...(scan.reportView ? { reportView: scan.reportView } : {}),
1131
1174
  ...(scan && scan._rulesetVersion ? { rulesetVersion: scan._rulesetVersion } : {}),
1132
1175
  ...(scan && scan._rulesetVersionSource ? { rulesetVersionSource: scan._rulesetVersionSource } : {}),
1133
1176
  ...(scan && scan._rulesetVersionMismatch ? { rulesetVersionMismatch: scan._rulesetVersionMismatch } : {}),
@@ -1376,6 +1419,8 @@ export function toHTML(scan, meta = {}) {
1376
1419
  <div class="card"><h2>STRIDE coverage</h2><table><tbody>${strideRows}</tbody></table></div>
1377
1420
  <div class="card"><h2>Top files</h2><table><tbody>${hotRows}</tbody></table></div>
1378
1421
  </div>
1422
+ ${(() => { const vl = viewLine(scan.reportView); return vl ? `<div class="card" style="margin-bottom:24px"><h2>Findings by disposition</h2><div class="meta">${_esc(vl)}</div></div>` : ''; })()}
1423
+ ${(() => { const h = haskellCoverageSummary(scan); if (!h) return ''; return `<div class="card" style="margin-bottom:24px"><h2>Haskell coverage &mdash; ${_esc(h.status)}</h2><div class="meta">${_esc(haskellCoverageLine(scan))}</div><div class="meta">Rule families: ${h.ruleFamilies.map((f) => `${_esc(f.id)} (${_esc(f.status)})`).join(', ')}</div>${h.reasons.map((r) => `<div class="meta">&bull; ${_esc(r)}</div>`).join('')}${h.limitations.map((r) => `<div class="meta">&bull; ${_esc(r)}</div>`).join('')}</div>`; })()}
1379
1424
  <div class="filters">
1380
1425
  <input id="q" placeholder="Filter by file, vuln, CWE&hellip;" />
1381
1426
  <select id="sev"><option value="">All severities</option><option>critical</option><option>high</option><option>medium</option><option>low</option><option>info</option></select>
@@ -1688,6 +1733,8 @@ export function toShipVerdict(scan, options = {}) {
1688
1733
  const conditions = Array.isArray(scan.scanHealth.conditions) ? scan.scanHealth.conditions : [];
1689
1734
  lines.push(c(` ${scan.scanHealth.status}: ${conditions[0] || 'analysis did not complete cleanly'}${conditions.length > 1 ? ` (+${conditions.length - 1} more)` : ''}`, SEV_COLOR.high));
1690
1735
  }
1736
+ { const hl = haskellCoverageLine(scan); if (hl) lines.push(c(` ${hl}`, DIM)); }
1737
+ { const vl = viewLine(scan.reportView); if (vl) lines.push(c(` ${vl}`, DIM)); }
1691
1738
  lines.push(` • ${sev.critical} critical · ${sev.high} high · ${advisoryCount} advisory`);
1692
1739
  // Per-category 0..100 score bars — Secrets / Permissions / Hooks / MCP / Agents.
1693
1740
  // Only render when at least one category has been contributed to (skip the
package/src/runScan.js CHANGED
@@ -3,12 +3,16 @@
3
3
  import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
+ import * as fsSync from 'node:fs';
6
7
  import { listFiles } from './util/glob.js';
8
+ import { buildScanInventory } from './scope/inventory.js';
7
9
  import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
8
10
  import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate, isInstructionFile } from './engine.js';
9
11
  import { appendScanSnapshot } from './posture/security-trend.js';
10
12
  import { recover as recoverFixHistory } from './posture/fix-history.js';
11
13
  import { stampScan } from './posture/ruleset-version.js';
14
+ import { annotateFindingRecords, buildScanRecord } from './pipeline/evidence-record.js';
15
+ import { SCANNER_VERSION } from './posture/version.js';
12
16
  import { isLanguageManifest, isLanguageExcludedPath, isLanguageSource, readExplicitExports, WALK_IGNORE_GLOBS, impactedFiles, buildImportGraph, importClosure } from './language/discovery.js';
13
17
 
14
18
  const DEP_FILE_NAMES = new Set([
@@ -77,8 +81,23 @@ const DEFAULT_IGNORE = [
77
81
  ...WALK_IGNORE_GLOBS,
78
82
  ];
79
83
 
80
- export async function readTree(root, { ignore = [] } = {}) {
81
- const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
84
+ // Directory names a Git-scoped scan still refuses regardless of Git state:
85
+ // dependency trees and Git's own metadata. The broader DEFAULT_IGNORE list
86
+ // (vendor, test, bin, ...) does NOT apply in Git-scoped mode: tracked code is
87
+ // not discarded because of its folder name (PRD FR 01).
88
+ const GIT_SCOPE_SAFETY_IGNORE = ['**/node_modules/**', '**/.git/**'];
89
+
90
+ export async function readTree(root, { ignore = [], gitScope = process.env.AGENTIC_SECURITY_GIT_SCOPE === '1', scope = {} } = {}) {
91
+ let entries;
92
+ let inventory = null;
93
+ if (gitScope) {
94
+ // One inventory decides eligibility before any content is read.
95
+ inventory = buildScanInventory(root, scope);
96
+ const drop = [...GIT_SCOPE_SAFETY_IGNORE, ...ignore];
97
+ entries = inventory.included.filter((rel) => !drop.some((g) => path.matchesGlob(rel, g)));
98
+ } else {
99
+ entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
100
+ }
82
101
  const fileContents = {};
83
102
  const depFileContents = {};
84
103
  const oversize = [];
@@ -104,6 +123,7 @@ export async function readTree(root, { ignore = [] } = {}) {
104
123
  else if (/\.proto$/i.test(base)) depFileContents[rel] = content;
105
124
  else if (/\.(?:graphql|gql)$/i.test(base)) depFileContents[rel] = content;
106
125
  else if (/\.tf$/i.test(base)) depFileContents[rel] = content;
126
+ else if (/^(?:BUCK|TARGETS)(?:\.v2)?$/.test(base) && /\bhaskell_|prebuilt_haskell_library/.test(content)) depFileContents[rel] = content;
107
127
  // A Kubernetes manifest is admitted on CONTENT, not on living under a
108
128
  // directory named k8s/ — see isKubernetesManifest. Without this the
109
129
  // k8s-admission detector is wired into the dispatch and never invoked by it.
@@ -113,7 +133,7 @@ export async function readTree(root, { ignore = [] } = {}) {
113
133
  // A CloudFormation template is a `.yaml`/`.json` that no path predicate can
114
134
  // recognise — same problem as a Kubernetes manifest, same fix, and the same
115
135
  // requirement that BOTH gates open: runFullScan re-filters this exact list.
116
- if (shouldScan(rel) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
136
+ if (shouldScan(rel, !!gitScope) || isKubernetesManifest(rel, content) || isCloudFormationTemplate(rel, content) || isInstructionFile(rel)) fileContents[rel] = content;
117
137
  // Auxiliary files: .properties files are referenced by Java rules
118
138
  // (e.g. OWASP Benchmark's benchmark.properties resolves algorithm
119
139
  // aliases). They are not scannable for vulns themselves, but the
@@ -127,7 +147,7 @@ export async function readTree(root, { ignore = [] } = {}) {
127
147
  const { contents } = readExplicitExports(root, manifests);
128
148
  for (const [rel, text] of Object.entries(contents)) depFileContents[rel] = text;
129
149
  } catch { /* exports are best-effort evidence */ }
130
- return { fileContents, depFileContents, oversize };
150
+ return { fileContents, depFileContents, oversize, inventory };
131
151
  }
132
152
 
133
153
  // Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
@@ -174,6 +194,16 @@ export async function languageFilesIgnoredByPolicy(root, fileContents, depFileCo
174
194
  return all.filter((rel) => (isLanguageSource(rel) || isLanguageManifest(rel)) && !isLanguageExcludedPath(rel) && !seen.has(rel)).sort().slice(0, 500);
175
195
  }
176
196
 
197
+ // FR 02: an explicit package layout for Buck or custom builds, `{ "packages": { "name": ["dir", ...] } }`.
198
+ // Read as data only; nothing in the map is executed and the build is not run.
199
+ function loadHaskellSourceMap(file, root) {
200
+ if (!file) return null;
201
+ const abs = path.resolve(root, file);
202
+ const doc = JSON.parse(fsSync.readFileSync(abs, 'utf8'));
203
+ if (!doc || typeof doc !== 'object' || !doc.packages || typeof doc.packages !== 'object') throw new Error(`Haskell source map ${file} must be {"packages": {"<name>": ["<dir>", ...]}}`);
204
+ return { packages: doc.packages, file: path.relative(root, abs) };
205
+ }
206
+
177
207
  export async function runScan(rootDir, opts = {}) {
178
208
  const root = path.resolve(rootDir);
179
209
  const startedAt = new Date().toISOString();
@@ -202,6 +232,7 @@ export async function runScan(rootDir, opts = {}) {
202
232
  // false claim of coverage.
203
233
  let completeScan = true;
204
234
  let sizeSkipped = [];
235
+ let scopeInventory = null;
205
236
  if (opts.fileContents) {
206
237
  // Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
207
238
  // construction a subset of the tree, not a scan of it.
@@ -209,7 +240,7 @@ export async function runScan(rootDir, opts = {}) {
209
240
  depFileContents = opts.depFileContents || {};
210
241
  completeScan = false;
211
242
  } else {
212
- ({ fileContents, depFileContents, oversize: sizeSkipped } = await readTree(root, opts));
243
+ ({ fileContents, depFileContents, oversize: sizeSkipped, inventory: scopeInventory } = await readTree(root, opts));
213
244
  }
214
245
 
215
246
  // Haskell/Nix sources under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not
@@ -254,10 +285,33 @@ export async function runScan(rootDir, opts = {}) {
254
285
 
255
286
  // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
256
287
  // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
257
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped }, opts.onProgress || (()=>{}));
288
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped, haskellSourceMap: loadHaskellSourceMap(opts.haskellSourceMap, root), gitScope: !!scopeInventory }, opts.onProgress || (()=>{}));
258
289
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
259
290
  // notify if the operator pinned a different version than what's installed.
260
291
  try { stampScan(root, scan); } catch {}
292
+ // FR 01: additive scope record (never replaces an existing field). Skipped paths are
293
+ // capped; the counts are exact.
294
+ if (scopeInventory) {
295
+ const reasons = {};
296
+ for (const x of scopeInventory.skipped) reasons[x.reason] = (reasons[x.reason] || 0) + 1;
297
+ scan.scope = {
298
+ schema: 1,
299
+ policy: scopeInventory.policy,
300
+ includedCount: scopeInventory.included.length,
301
+ // The included list itself lets a later comparison tell "no longer reported because it was fixed" from "no longer reported
302
+ // because a narrower scan did not look". Kept only when small enough not to bloat the record.
303
+ ...(scopeInventory.included.length <= 5000 ? { included: scopeInventory.included } : { includedOmitted: true }),
304
+ skippedCount: scopeInventory.skipped.length,
305
+ skippedByReason: reasons,
306
+ skipped: scopeInventory.skipped.slice(0, 500),
307
+ overrides: scopeInventory.overrides,
308
+ coverageConditions: scopeInventory.skipped.filter((x) => x.coverageCondition).slice(0, 100),
309
+ };
310
+ }
311
+ // FR 10: additive evidence contract. Finding records carry the source hash and span; the scan record ties the run to a commit,
312
+ // dirty-file digests, the ignore-policy digest and the invocation. Neither replaces an existing field.
313
+ try { annotateFindingRecords(scan.findings, fileContents, { engineVersion: SCANNER_VERSION }); } catch {}
314
+ try { scan.scanRecord = buildScanRecord({ root, scan, meta: { mode: opts.changedSince ? 'incremental' : 'full', durationMs: Date.now() - t0 }, argv: opts.invocation || [], engineVersion: SCANNER_VERSION }); } catch {}
261
315
  // Append snapshot to history for /security-trend (non-blocking, never throws)
262
316
  try { appendScanSnapshot(scan, root); } catch {}
263
317
  return {