Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions .github/requirements-semgrep.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,30 @@
# release turning a green branch red on a commit that touched nothing teaches everybody to
# ignore the check.
#
# THIS EXACT VERSION IS THE ONE THE GATE'S NUMBERS WERE MEASURED WITH. On 2026-09-22, against
# commit 693bced exported to a clean directory, semgrep 1.177.0 with `--config p/default` ran
# 131 rules over 301 files and reported zero findings and zero errors at level error. Those
# numbers are quoted in .github/scripts/semgrep_gate.py, so moving this line means measuring
# again rather than assuming the report will look the same.
# THE GATE'S NUMBERS ARE MEASURED WITH THE VERSION BELOW. Measured again on 2026-10-14, when the
# version moved from 1.178.0 to 1.180.0, the newest stable release on the package index that day,
# with the pull request that brought snapshots to the window. That pull request's own Semgrep run,
# on the merge of commit c8ea3cb into main with `--config p/default`: semgrep ran 374 rules over
# 399 files and reported zero findings, and the gate read zero errors at level error and 419 rules.
# The two rule counts are one run read two ways - 374 is semgrep's own summary, 419 the timing
# list in the report, which is what the gate counts. The run on the commit before, d39889d, gave
# the same numbers. Before them the numbers here were from 1.177.0 on 2026-09-22 (131 rules, 301
# files), and 1.178.0 was never measured. Moving this line means measuring again rather than
# assuming the report will look the same.
#
# 301 OF 516 FILES, AND THAT IS NOT A FAULT. Semgrep's own default ignore patterns exclude
# tests/, which is 212 of this repository's files. So a healthy run of this tree reads src/,
# site/, .github/ and the root - about three hundred files - and a collapse to a handful is the
# shape of a scan that ran somewhere else. The gate prints the number on every run for exactly
# that reason.
# 399 OF 743 FILES, AND THAT IS NOT A FAULT. Semgrep's own default ignore patterns exclude
# tests/, which is 336 of this repository's files, one picture is skipped for being over 1 MB, and
# the seven other pictures are not among the 399 either - that last part is the arithmetic of the
# count, not a line semgrep prints. So a healthy run of this tree reads src/, site/, .github/,
# packaging/ and the root - about four hundred files - and a collapse to a handful is the shape of
# a scan that ran somewhere else. The gate prints the number on every run for exactly that reason.
#
# Licence, read on 2026-09-22 from this exact version's own metadata rather than recalled. The
# Licence, read on 2026-09-22 from that version's own metadata rather than recalled, and read again
# on 2026-10-14 from 1.180.0's, which says the same. The
# package index reports `license_expression` as LGPL-2.1-or-later, with the older `license`
# field null and no licence classifier - which is the modern shape and worth saying, because a
# reader checking the field that used to hold this would find nothing and could conclude the
# licence was unstated. It analyses the source tree in CI and is never linked into anything this
# project ships, so it creates no distribution obligation - see THIRD-PARTY-NOTICES.md for what
# does.
semgrep==1.178.0
semgrep==1.180.0
41 changes: 28 additions & 13 deletions .github/scripts/semgrep_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@

A scan error blocks too. A rule that failed to run is not a rule that found
nothing, and "the scan was green" must never mean "the scan did not happen".
Only entries at level "error" count: a clean run of this repository carries four
entries at level "warn", and none of them is a reason to stop anybody.
Only entries at level "error" count: a clean run of this repository carried four
entries at level "warn" with 1.177.0 on 2026-09-22, and none of them is a reason
to stop anybody. That count was not taken again with 1.180.0, because this gate
prints only the level it blocks on.

A scan that read too few files blocks as well, and so does one carrying too few
rules. Both numbers are printed on every run rather than only when they fire.
Expand All @@ -62,17 +64,24 @@

What a healthy run of THIS repository looks like
-------------------------------------------------
Measured 2026-09-22 against commit 693bced, exported to a clean directory so
that exactly the 516 files a checkout carries were present, semgrep 1.177.0 with
`--config p/default`:
Measured 2026-10-14 by this repository's own Semgrep run on pull request 61,
the merge of commit c8ea3cb into main, semgrep 1.180.0 with `--config p/default`.
This gate's last line on that run:

131 rules, 301 files, 0 findings, 0 errors at level error, 4 at level warn
semgrep: 0 blocking, 0 other, 0 scan error(s), 399 file(s) scanned, 419 rule(s)

301 rather than 516 because semgrep's own default ignore patterns exclude
tests/, which is 212 of this repository's files. That is not a fault and not a
setting of ours - it is the tool's default, and it is worth knowing before
somebody reads "301" as a broken scan. The files that ARE read are src/, site/,
.github/ and the root.
Semgrep's own summary on the same run says 374 rules. Both are true - the gate
counts the timing list in the report, which is the longer of the two.

399 rather than the 743 files a checkout carries, because semgrep's own default
ignore patterns exclude tests/, which is 336 of them, and the eight pictures are
not read either. That is not a fault and not a setting of ours - it is the
tool's default, and it is worth knowing before somebody reads "399" as a broken
scan. The files that ARE read are src/, site/, .github/, packaging/ and the root.

The first measurement, 2026-09-22 with 1.177.0 against commit 693bced exported
to a clean directory, was 131 rules over 301 files - before the two Python
scripts arrived, which the floors below come back to.

Findings decided once and recorded
-----------------------------------
Expand Down Expand Up @@ -100,9 +109,15 @@
# a gate that goes red for a legitimate reason is a gate people learn to bypass. Measured on
# this repository:
#
# files 301 (before the Python scripts existed), 306, 309 - it tracks the tree
# files 301 (before the Python scripts existed), 306, 309, 399 - it tracks the tree
# rules 131, 374, 419 - it tracks WHICH LANGUAGES ARE IN THE TREE, not the ruleset alone.
# Two .py files took it from 131 to 374 by pulling in Python's rules.
# Two .py files took it from 131 to 374 by pulling in Python's rules. On 2026-10-14,
# with 1.180.0, ONE run said both 374 (semgrep's summary) and 419 (the timing list
# this gate counts), so the last two may be one run read two ways - the earlier runs
# did not keep both, and that is not settled.
#
# Both floors were checked against that run: 399 files over 200, 419 rules over 50. Still floors
# against a collapse, far under a healthy run on purpose.
#
# So these refuse a collapse and say nothing about a drift of tens. Raising them is a deliberate
# act that means measuring again, exactly like the pinned scanner version.
Expand Down
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,32 @@ is not part of this repository.

## [Unreleased]

### Added

- Snapshots in the window. A "Snapshots..." button beside Export... offers "Take snapshot...", which
writes this machine to a file you choose - the same file `bws snapshot create` writes, readable by
either - and "Compare with this machine...", which reads a snapshot and shows what changed since it
in place of the list: one row per field that differs, the entries added and removed, and above
the table everything that makes the comparison less than exact, such as a snapshot taken on
another machine or without administrator rights. "Back to the list" or Escape returns. Nothing in
either changes a service, and a file at the chosen path that is not a snapshot is refused and left
as it was. A value read from a snapshot shows a control or text direction character as its code
point, as the command line does.

### Changed

- A comparison no longer lists, entry by entry, a field Windows refused to both sides. On a real
machine a few service descriptions point at a language file that is not there, and Windows refuses
them to every reader - so every comparison listed the same entries as "not fully compared" while
nothing about them had changed. Such a field is now one line among the caveats, naming the entries,
and a snapshot compared with a copy of itself says "No differences." again. In `--json` the field
and its entries are in a new `refusedByBoth` list at the end of the document, and those entries are
no longer in `notFullyCompared`. A field one side holds a value for and the other does not is still
listed against its entry, and the sentence there now says "holds no value for" rather than "never
read".
- The window cannot be made narrower than 1000 points, up from 900, so the new button fits in the
bar of actions.

### Fixed

- `bws kill`, and Force stop in the window, no longer announce that they end a service's process
Expand Down
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,10 @@ A tool that quietly fails to cover something is worse than one that says what it

**Not built yet - the second half of the audit story:**

- **Snapshots are on the command line only.** The window shows, searches and changes, and it does
not take or compare snapshots. Use `bws snapshot` beside it.
- **Comparing two snapshot files is on the command line only.** The window takes a snapshot and
compares one with this machine, and `bws snapshot diff EARLIER LATER` compares two files - from two
machines, or from two moments. The window does not take a note with a snapshot either - `--note`
does.
- **No stock Windows baseline.** The opening screen has a place for *how many of these are not
from a clean install* and says it is not counted yet rather than showing a number it cannot
stand behind.
Expand All @@ -355,7 +357,8 @@ A tool that quietly fails to cover something is worse than one that says what it
by script from the commands the diff shows you.
- **No creating, deleting or repointing a service.** The launch path is read and reported, and it
is not edited.
- **No recovery actions.** Not read, not shown, not searchable.
- **No recovery actions in the list.** They are read before a process is ended, so a plan can warn
that Windows will start a service again, and they are not shown, searchable or editable.

**By design:**

Expand Down Expand Up @@ -563,8 +566,11 @@ by another program, which it then says, leaves alone and saves nothing over. A r
every operation before offering it, and copies the name or
everything about the entry, display name and description included. *Export...* writes the rows on screen, in the columns
you have on and the order you sorted them into, to a CSV file separated the way your Windows region
settings separate lists, so a double click opens it in columns. Ctrl+C over the list copies
everything about the chosen entry. Escape backs out of the innermost thing first. *Donate*, at the
settings separate lists, so a double click opens it in columns. *Snapshots...* beside it takes a
snapshot of the machine to a file, the same file `bws snapshot create` writes, or compares one with
the machine as it is now and shows what changed in place of the list, one row per field. Ctrl+C
over the list copies everything about the chosen entry. Escape backs out of the innermost thing
first. *Donate*, at the
right end of the row above the search box, opens the project's support page in your browser - and
in a window running as administrator it asks the desktop to open it, so the browser does not get
those rights.
Expand Down Expand Up @@ -627,7 +633,8 @@ item are the plan. Until then a snapshot at deployment and a diff against the ma

**Working end to end:** the window and the command line over the same engine, the query language,
plans with preview, cascade, refusal and the way back, stop, start, restart, force stop, force
restart and start type, snapshots and diffs between files and against the live machine, signatures,
restart and start type, snapshots and diffs between files and against the live machine - in the
window too, against the live machine - signatures,
privileges, security descriptors, triggers, memory, per-user folding, keyboard and screen reader in
the window, CSV export, JSON and exit codes on the command line.

Expand Down
14 changes: 12 additions & 2 deletions src/Bws.Cli/DiffJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ [.. diff.Uncertain.Select(Presence)],
[.. diff.Changed.Select(Changed)],
[.. diff.NotFullyCompared.Select(Changed)],
diff.NeitherRead,
new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later)),
new LeftOutDocument(diff.LeftOut.Earlier, diff.LeftOut.Later),
[.. diff.RefusedByBoth.Select(field => new RefusedDocument(field.Field, field.Entries))]),
Options);

private static PresenceDocument Presence(EntryPresence entry) =>
Expand Down Expand Up @@ -86,6 +87,12 @@ private static ChangedDocument Changed(ChangedEntry entry) =>
/// Per-user session copies neither side was compared on, counted per side. Added 2026-09-30
/// (D-1) - an addition, so nothing a script already reads changes.
/// </param>
/// <param name="RefusedByBoth">
/// Fields Windows refused to both snapshots, each with the entries it refused it on. Added
/// 2026-10-14 (backlog 559, owner's decision) and LAST, so the document only grew at its end.
/// <b>What moved, said for whoever reads <paramref name="NotFullyCompared"/>:</b> an entry whose
/// only uncompared field was refused to both sides is no longer there - it is named here instead.
/// </param>
private sealed record DiffDocument(
bool Differs,
CaveatsDocument Caveats,
Expand All @@ -95,7 +102,10 @@ private sealed record DiffDocument(
IReadOnlyList<ChangedDocument> Changed,
IReadOnlyList<ChangedDocument> NotFullyCompared,
IReadOnlyList<string> NeitherRead,
LeftOutDocument InstancesLeftOut);
LeftOutDocument InstancesLeftOut,
IReadOnlyList<RefusedDocument> RefusedByBoth);

private sealed record RefusedDocument(string Field, IReadOnlyList<string> Entries);

/// <param name="NotKnown">
/// Metadata one of the two files does not carry, as the field names the file uses - a
Expand Down
21 changes: 18 additions & 3 deletions src/Bws.Cli/DiffText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ internal static string Render(SnapshotDiff diff)
Changed(text, "cli.diff.changed", diff.Changed);

// Also its own heading, for the same reason one level down. These entries have
// nothing different about them - one of the two snapshots simply never read a field,
// so nobody can say either way. Filed under "changed" they read as drift that is not
// nothing different about them - one of the two snapshots holds no value for a field,
// so nobody can say either way. A field refused to BOTH is not here but in the caveats. Filed under "changed" they read as drift that is not
// there, which is how the first version of this printed "5 changed, 0 differences".
Changed(text, "cli.diff.notFullyCompared", diff.NotFullyCompared);

Expand Down Expand Up @@ -80,7 +80,11 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff)
(diff.LeftOut.Any, () => Texts.Of("cli.diff.caveat.instancesLeftOut", diff.LeftOut.Earlier, diff.LeftOut.Later))
];

var said = lines.Where(line => line.Says).Select(line => line.Line()).ToList();
var said = lines
.Where(line => line.Says)
.Select(line => line.Line())
.Concat(diff.RefusedByBoth.Select(Refused))
.ToList();

foreach (var line in said)
{
Expand All @@ -93,6 +97,17 @@ private static void Caveats(StringBuilder text, SnapshotDiff diff)
}
}

/// <summary>
/// A field Windows refused to both snapshots, said once with the entries it was refused on -
/// backlog 559, owner's decision of 2026-10-14. Until then each of those entries stood under
/// "not fully compared", so this machine compared with itself a minute later printed ten of
/// them every time.
/// </summary>
private static string Refused(RefusedField field) =>
field.Entries.Count == 1
? Texts.Of("cli.diff.caveat.refusedByBoth.one", field.Field, field.Entries.Count, string.Join(", ", field.Entries))
: Texts.Of("cli.diff.caveat.refusedByBoth.many", field.Field, field.Entries.Count, string.Join(", ", field.Entries));

private static void Presence(StringBuilder text, string heading, IReadOnlyList<EntryPresence> entries)
{
if (entries.Count == 0)
Expand Down
38 changes: 10 additions & 28 deletions src/Bws.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,20 +135,12 @@

if (options.Live)
{
// Signatures and hashes are read here for the same reason snapshot create reads
// them: the file on the other side has them. Comparing against a reading that
// skipped them would mark every entry as "one side never read this", which is
// 810 admissions and no answer.
var before2 = stopwatch.ElapsedMilliseconds;
entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths));
inspected = stopwatch.ElapsedMilliseconds - before2;

// The same third family, and for the same sentence: the file on the other side has
// it. Left out, every entry lands in "neitherRead" - which is not a difference, so a
// comparison reports none while the field goes uncompared.
before2 = stopwatch.ElapsedMilliseconds;
entries = RequiredByPass.Fill(entries, catalog!);
asked = stopwatch.ElapsedMilliseconds - before2;
// Signatures, hashes and who depends on each entry are read here for the same reason
// snapshot create reads them: the file on the other side has them. Comparing against a
// reading that skipped them would mark every entry as "one side never read this",
// which is 810 admissions and no answer. One call since 2026-10-14, shared with
// snapshot create and the window - SnapshotPasses says why.
entries = SnapshotPasses.Fill(entries, catalog!, new WindowsBinaryInspector(networkPaths), out inspected, out asked);

after = Snapshot.Of(entries, note: null, new SystemClock());
}
Expand Down Expand Up @@ -224,20 +216,10 @@
return ExitCode.Usage;
}

// Signatures and the hash are read every time here, unlike in a listing. A snapshot
// is taken deliberately and kept for months, so being comparable against the next
// one matters more than the several seconds it costs - and a snapshot missing them
// would compare against one that has them as though the machine had changed.
var before = stopwatch.ElapsedMilliseconds;
entries = SecondPass.Fill(entries, new WindowsBinaryInspector(networkPaths));
inspected = stopwatch.ElapsedMilliseconds - before;

// AND WHO DEPENDS ON EACH ENTRY, EVERY TIME, on the argument above and for a small fraction
// of the price - RequiredByPass carries the measurement. No switch here: one taken without it
// would compare against one that has it as though every service had lost its dependents.
before = stopwatch.ElapsedMilliseconds;
entries = RequiredByPass.Fill(entries, catalog!);
asked = stopwatch.ElapsedMilliseconds - before;
// Signatures, the hash and who depends on each entry are read every time here, unlike in a
// listing - SnapshotPasses carries the argument, and since 2026-10-14 the one place it is
// read for the command line and the window alike.
entries = SnapshotPasses.Fill(entries, catalog!, new WindowsBinaryInspector(networkPaths), out inspected, out asked);

var snapshot = Snapshot.Of(entries, options.Note, new SystemClock());
var target = SnapshotFiles.Target(options.Path, snapshot.Metadata);
Expand Down
Loading
Loading